diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8243353d..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 @@ -251,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 @@ -306,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' }} @@ -328,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' }} @@ -351,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 @@ -419,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 a003c661ba..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 @@ -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 cbbbe069a5..d8244b4fec 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,19 +25,23 @@ 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`, `ellipse-dashed.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 | @@ -110,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 b0ff196c55..98e1290c29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4638,10 +4638,11 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.11.1" +version = "0.12.0" dependencies = [ "arboard", "async-io", + "chrono", "core-graphics2", "dispatch2", "eframe", @@ -4683,7 +4684,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.11.1" +version = "0.12.0" dependencies = [ "crc32fast", "flate2", @@ -4693,7 +4694,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4706,7 +4707,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.11.1" +version = "0.12.0" dependencies = [ "base64 0.22.1", "cap-std", @@ -4716,8 +4717,8 @@ dependencies = [ "photocraft-doc", "photocraft-engine", "photocraft-format", - "photocraft-geom", "photocraft-io", + "photocraft-raw", "rmcp", "rustix 1.1.5", "schemars", @@ -4729,13 +4730,14 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-automation", "photocraft-codecs", "photocraft-engine", "photocraft-format", "photocraft-io", + "photocraft-raw", "serde_json", "tokio", "winresource", @@ -4743,7 +4745,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.11.1" +version = "0.12.0" dependencies = [ "moxcms", "rayon", @@ -4751,7 +4753,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.11.1" +version = "0.12.0" dependencies = [ "exr", "flate2", @@ -4772,7 +4774,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-cms", "serde", @@ -4780,7 +4782,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.11.1" +version = "0.12.0" dependencies = [ "criterion", "photocraft-color", @@ -4795,7 +4797,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4806,9 +4808,10 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.11.1" +version = "0.12.0" dependencies = [ "blake3", + "chrono", "craft-print", "exr", "flate2", @@ -4844,7 +4847,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.11.1" +version = "0.12.0" dependencies = [ "blake3", "crc32fast", @@ -4867,17 +4870,18 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.11.1" +version = "0.12.0" dependencies = [ "half", "log", + "photocraft-cms", "photocraft-color", "photocraft-compose", "photocraft-doc", @@ -4891,14 +4895,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.11.1" +version = "0.12.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.11.1" +version = "0.12.0" dependencies = [ "blake3", "craft-docpath", @@ -4928,18 +4932,18 @@ dependencies = [ [[package]] name = "photocraft-ipp" -version = "0.11.1" +version = "0.12.0" [[package]] name = "photocraft-ops" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4950,7 +4954,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4965,7 +4969,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "photocraft-psd", @@ -4976,7 +4980,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4987,7 +4991,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "image", @@ -5002,7 +5006,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.11.1" +version = "0.12.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -5014,18 +5018,15 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.11.1" +version = "0.12.0" dependencies = [ "memory-stats", - "photocraft-color", - "photocraft-geom", - "photocraft-raster", "serde_json", ] [[package]] name = "photocraft-text" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "parley", @@ -5040,7 +5041,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.11.1" +version = "0.12.0" dependencies = [ "base64 0.22.1", "craft-print", @@ -5075,6 +5076,7 @@ dependencies = [ "serde", "serde_json", "sys-locale", + "tempfile", "toml", "unicode-bidi", "winsafe", @@ -5082,7 +5084,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.11.1" +version = "0.12.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5093,7 +5095,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.11.1" +version = "0.12.0" dependencies = [ "eframe", "egui", @@ -5115,7 +5117,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.11.1" +version = "0.12.0" dependencies = [ "flate2", "js-sys", @@ -8711,8 +8713,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.11.1" +version = "0.12.0" dependencies = [ + "chrono", "serde", "serde_json", "toml", diff --git a/Cargo.toml b/Cargo.toml index b3f49802e3..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.11.1" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,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 7554fcd8f2..74a5c78f36 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

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

+

PhotoCraft

@@ -202,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

@@ -217,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. @@ -339,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/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 0e3429d074..c2497e1bf1 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,21 +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>] [--dds-compression ] [--dds-mipmaps] + 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>] [--dds-compression ] [--dds-mipmaps] + 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>] [--dds-compression ] [--dds-mipmaps] + 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 @@ -73,20 +79,44 @@ struct Subcommand { const SUBCOMMANDS: &[Subcommand] = &[ Subcommand { name: "convert", - values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"], - bare: &["--tiff-layers", "--dds-mipmaps"], + 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", "--dds-compression"], + 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", "--dds-compression"], + 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, }, @@ -226,6 +256,18 @@ 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"))?; @@ -251,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())?; @@ -456,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}"); } @@ -763,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 80% rename from apps/photocraft-cli/tests/cli.rs rename to apps/photocraft-cli/tests/it/cli.rs index 19258ca9ed..c31239d14f 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/it/cli.rs @@ -563,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] @@ -609,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")); @@ -796,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/src/web.rs b/apps/photocraft-web/src/web.rs index a248ecbb13..fc17612908 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -17,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"; @@ -322,6 +323,12 @@ 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)); if !dropped.is_empty() { @@ -384,8 +391,8 @@ impl eframe::App for WebShell { 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()) diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 505947e222..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 } 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/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 33be58cd4c..9b08d4d292 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -12,10 +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, 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. +//! 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 @@ -35,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; @@ -244,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 { @@ -258,9 +274,10 @@ pub struct Candidate { /// Rank of an adapter (lower is better): high-performance device types first (egui's power /// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for -/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback -/// for presenting the window (see [`backends`]). 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 { @@ -276,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, @@ -329,15 +346,18 @@ 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()) } -/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the -/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of -/// the instance). -pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool { - let Some(c) = adapters.get(chosen) else { return false }; - os == Os::Windows - && c.vendor == INTEL - && c.backend == wgpu::Backend::Dx12 - && (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)) +/// 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 @@ -349,8 +369,8 @@ 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). +/// 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) { @@ -396,9 +416,11 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: *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, vulkan_left_out) { + 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()) })); @@ -772,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, false)); + 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, false)); + 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)); @@ -789,6 +811,27 @@ 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. @@ -824,8 +867,8 @@ mod tests { 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!(intel_dx12_applied(&listed, i, Os::Windows, true)); - assert!(!intel_dx12_applied(&listed, i, Os::Windows, false)); + assert_eq!(dx12_default_applied(&listed, i, Os::Windows, true), Some("Intel")); + assert_eq!(dx12_default_applied(&listed, i, Os::Windows, false), None); } #[test] diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 5c8f0697dd..6a0cc8f052 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -91,27 +91,36 @@ 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 { @@ -416,6 +425,20 @@ 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] diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index b4cf01d3df..1b58e89f03 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -102,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) @@ -526,6 +524,13 @@ fn main() -> eframe::Result { #[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, @@ -786,6 +791,9 @@ fn main() -> eframe::Result { 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 { // Keep an earlier note, such as a requested adapter that couldn't be used. @@ -803,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(|| { 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 5a839b6025..7a939639af 100644 --- a/apps/photocraft/src/portal_save/tests.rs +++ b/apps/photocraft/src/portal_save/tests.rs @@ -235,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/services.rs b/apps/photocraft/src/services.rs index 999952f186..cb7dae9fc5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -1,10 +1,8 @@ //! 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::{Cell, RefCell}; use std::future::Future; @@ -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"]; @@ -45,6 +44,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[ ("Photoshop", &["psd", "psb", "psdt"]), ("Affinity", &["af", "afdesign", "afphoto", "afpub"]), ("Krita", &["kra", "krz"]), + ("GIMP", &["xcf", "xcfgz", "gz"]), ("OpenRaster", &["ora"]), ("Paint.NET", &["pdn"]), ("SVG", SVG_EXTS), @@ -66,10 +66,12 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[ ("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", "ase"]), + ("Swatches", &["aco", "act", "ase"]), ("Keyboard Shortcuts", &["kys"]), ]; @@ -101,11 +103,12 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("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)> { @@ -197,10 +200,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, }; 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 } => { @@ -208,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); @@ -243,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)) } } }) @@ -255,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)) }) } } }; @@ -269,8 +272,22 @@ 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. @@ -329,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() } @@ -386,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)); })), @@ -414,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. @@ -526,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| { @@ -543,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, @@ -558,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, @@ -570,7 +638,7 @@ pub fn native(automation: Option) -> clipboard_get_image: Some({ let clip = clip.clone(); let unreadable = clip_unreadable.clone(); - Box::new(move || { + 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, @@ -634,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::*; @@ -713,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] @@ -830,6 +865,9 @@ 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"))); } @@ -859,6 +897,10 @@ mod tests { 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] @@ -1012,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::(); @@ -1289,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/tests/command_line_usage.rs b/apps/photocraft/tests/it/command_line_usage.rs similarity index 100% rename from apps/photocraft/tests/command_line_usage.rs rename to apps/photocraft/tests/it/command_line_usage.rs 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/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/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 c333cbc737..590260962e 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -19,6 +19,7 @@ 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 diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..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 @@ -166,6 +176,9 @@ sync_github_name = true real_name = "Sidney Markowitz" sync_github_name = true +[people.theoabw] +real_name = "Theo Wilenius" + [people.TaylorBurke] sync_github_name = true @@ -191,3 +204,50 @@ 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 535c3736c6..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`) 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 6c8ea602c9..6825abd511 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -210,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 7c05072419..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,6 +303,7 @@ 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 @@ -312,6 +319,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { 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, @@ -383,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/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 2c68a064a3..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; diff --git a/crates/affinity/tests/compound_limits.rs b/crates/affinity/tests/it/compound_limits.rs similarity index 97% rename from crates/affinity/tests/compound_limits.rs rename to crates/affinity/tests/it/compound_limits.rs index 76f2e6e4a1..6801f7f6d6 100644 --- a/crates/affinity/tests/compound_limits.rs +++ b/crates/affinity/tests/it/compound_limits.rs @@ -48,8 +48,10 @@ fn isolated(name: &str, check: impl FnOnce()) { 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", name, "--nocapture"]) + .args(["--exact", &test, "--nocapture"]) .env("PHOTOCRAFT_COMPOUND_CASE", name) .spawn() .unwrap(); 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/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 bb6b47656c..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> { diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 6cefbbd4c1..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, @@ -606,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. @@ -653,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", @@ -697,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()), @@ -817,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)) } 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 61ccfc0038..aa76fb814a 100644 --- a/crates/algo/src/other2.rs +++ b/crates/algo/src/other2.rs @@ -124,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 75fa9a4bfb..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; } } @@ -1585,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 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 d40ff9cda8..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; diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index e1c1d89de0..95ed668e68 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -141,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; @@ -154,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 { @@ -166,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); diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index e060913c9b..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 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/transform.rs b/crates/algo/src/transform.rs index 91260bd285..3edd0a1879 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -254,6 +254,21 @@ pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> { 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; @@ -264,11 +279,13 @@ fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> { ) } +/// 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; @@ -294,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); } @@ -366,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)); @@ -495,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) } @@ -551,6 +567,8 @@ mod tests { } } + /// 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); @@ -563,6 +581,20 @@ mod tests { 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); @@ -808,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 54a6c7c868..dd20daf6c9 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -15,7 +15,7 @@ use photocraft_color::PixelFormat; use photocraft_geom::{Rect, TileCoord}; -use photocraft_raster::{Surface, Tile}; +use photocraft_raster::{Interrupt, Surface, Tile}; use std::sync::Arc; use crate::transform::{Interp, bicubic}; @@ -75,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)); @@ -104,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. @@ -149,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 { @@ -208,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; + // 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; } @@ -406,7 +431,8 @@ 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; } @@ -430,6 +456,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 }; #[cfg(target_arch = "wasm32")] let done: Vec<(TileCoord, Arc)> = tiles.iter().zip(bins.iter()).filter_map(work).collect(); + if ctl.cancelled() { + return out; + } out.put_tiles(done); out } @@ -503,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 @@ -538,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 f5e88b3591..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,6 +27,8 @@ 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"] } 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 3db25c4fdd..c0369c917d 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -115,10 +115,19 @@ impl Headless { 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()))?; opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default(); - 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; + 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) @@ -342,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(); @@ -577,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(); @@ -757,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/server.rs b/crates/automation/src/server.rs index 567d0b4837..46905daf38 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -1008,6 +1008,9 @@ 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), @@ -1052,8 +1055,8 @@ impl PhotocraftMcp { 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); @@ -1194,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 99dedac77d..90fbdcd1ab 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -340,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"], _ => &[], }; @@ -356,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") @@ -374,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()) @@ -759,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()); @@ -821,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}"); } @@ -833,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}"); } @@ -847,6 +875,8 @@ mod tests { "historyLog.filePath", ".historyLog.filePath", "plugIns.additionalPluginsFolder", + "fileHandling.lutFolder", + "fileHandling", "scratchDisks.disks", ".", "..historyLog.filePath", @@ -857,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] @@ -887,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 100% rename from crates/automation/tests/krita.rs rename to crates/automation/tests/it/krita.rs 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 99% rename from crates/automation/tests/mcp.rs rename to crates/automation/tests/it/mcp.rs index 5a9fa7c56e..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(); @@ -770,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 70b4ab3c9c..9109f48ed2 100644 --- a/crates/cms/README.md +++ b/crates/cms/README.md @@ -75,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/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 04d11247f4..f30c17c82e 100644 --- a/crates/cms/src/transform.rs +++ b/crates/cms/src/transform.rs @@ -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 }); 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/gpu_matrix_shaper.rs b/crates/cms/tests/it/gpu_matrix_shaper.rs similarity index 100% rename from crates/cms/tests/gpu_matrix_shaper.rs rename to crates/cms/tests/it/gpu_matrix_shaper.rs 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 539365c95b..46a33e89b3 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -47,7 +47,9 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | 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,13 +68,16 @@ 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 + 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 @@ -104,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 @@ -145,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 d35c7d1092..cadc2b4bc8 100644 --- a/crates/codecs/src/codecs/dds.rs +++ b/crates/codecs/src/codecs/dds.rs @@ -117,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)) } } @@ -195,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 => { 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 1b268ac559..2b32a2b7c4 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -164,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/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 a300de4bd1..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; @@ -255,15 +256,21 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result= 1.0 && y >= 1.0 { @@ -271,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)) { @@ -284,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; @@ -297,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> { @@ -347,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 1980e887c8..efdbbf8229 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,6 +1,7 @@ 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; @@ -8,6 +9,7 @@ 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 f894b3a30d..085246b3f1 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -676,8 +676,8 @@ mod stereo_tests { #[test] fn reads_both_defined_views() { - assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal)); - assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical)); + 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); @@ -687,6 +687,24 @@ mod stereo_tests { 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]); @@ -699,7 +717,7 @@ mod stereo_tests { #[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::Horizontal, PngStereo::Vertical] { + 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)); @@ -716,12 +734,12 @@ mod stereo_tests { #[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::Vertical), png_interlaced: true, ..Default::default() }; + 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::Vertical)); + 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::Horizontal)).unwrap(); - assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal)); + 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())); } } 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 00d47d0702..2e6885b6e4 100644 --- a/crates/codecs/src/codecs/tiff_exif.rs +++ b/crates/codecs/src/codecs/tiff_exif.rs @@ -20,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; @@ -92,7 +96,20 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option { /// 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; } 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/format.rs b/crates/codecs/src/format.rs index 24611bd90d..a602da57b7 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -34,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. @@ -68,6 +72,11 @@ 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.", @@ -77,11 +86,6 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ "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 \ @@ -101,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, @@ -122,6 +126,8 @@ impl Format { Format::Fits, Format::Dpx, Format::Cineon, + Format::Dicom, + Format::Iff, ]; pub fn caps(self) -> FormatCaps { @@ -156,6 +162,8 @@ impl Format { Format::Fits => "FITS", Format::Dpx => "DPX", Format::Cineon => "Cineon", + Format::Dicom => "DICOM", + Format::Iff => "Amiga IFF", } } @@ -183,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"], } } @@ -208,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", } } @@ -264,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 }, } } @@ -322,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 5a03e7d283..adc737fee8 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -454,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. @@ -468,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. @@ -477,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 { @@ -646,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 8c06694b9a..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,7 +26,7 @@ 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, stereo as png_stereo}; @@ -39,14 +40,14 @@ pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; pub use crate::options::{ - DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression, + 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 { @@ -83,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), @@ -139,6 +142,12 @@ 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. @@ -160,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 { @@ -172,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; @@ -263,7 +301,9 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result 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 08de328574..1f7b249722 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -103,18 +103,18 @@ pub enum PngCompression { /// 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 { - /// Left and right views side by side, left first. - Horizontal, - /// Left and right views stacked, left on top. - Vertical, + /// 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::Horizontal), - 1 => Some(Self::Vertical), + 0 => Some(Self::CrossFuse), + 1 => Some(Self::DivergingFuse), _ => None, } } @@ -122,8 +122,8 @@ impl PngStereo { /// The `sTER` mode byte. pub fn mode(self) -> u8 { match self { - Self::Horizontal => 0, - Self::Vertical => 1, + Self::CrossFuse => 0, + Self::DivergingFuse => 1, } } } @@ -139,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 { @@ -244,6 +275,8 @@ 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, @@ -275,6 +308,7 @@ 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, 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/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 74% rename from crates/codecs/tests/dds.rs rename to crates/codecs/tests/it/dds.rs index ebf4042f48..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"); @@ -170,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/dds_write.rs b/crates/codecs/tests/it/dds_write.rs similarity index 97% rename from crates/codecs/tests/dds_write.rs rename to crates/codecs/tests/it/dds_write.rs index 3225dd14d6..e51e21ceda 100644 --- a/crates/codecs/tests/dds_write.rs +++ b/crates/codecs/tests/it/dds_write.rs @@ -97,6 +97,9 @@ fn level(file: &[u8], i: u32) -> Image { 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() } @@ -345,7 +348,7 @@ fn compression_names_round_trip() { /// Encode timings on a synthetic ~30 MP texture (6336×4752). Ignored by default; run in release: /// /// ```sh -/// cargo test --release -p photocraft-codecs --test dds_write -- --ignored --nocapture +/// cargo test --release -p photocraft-codecs --test it -- dds_write:: --ignored --nocapture /// ``` #[test] #[ignore] 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 100% rename from crates/codecs/tests/exr_multipart.rs rename to crates/codecs/tests/it/exr_multipart.rs 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 9c6cb8cb6f..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::*; @@ -242,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 100% rename from crates/codecs/tests/tiff_exif.rs rename to crates/codecs/tests/it/tiff_exif.rs 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 38e34fcd12..d122a09993 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -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) => { @@ -664,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 @@ -678,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 @@ -786,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, @@ -898,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 @@ -1389,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 { @@ -1498,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. @@ -1736,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 cbdf7f35a2..264dc3529b 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -87,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, + } }) } @@ -997,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; @@ -1009,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); } } } @@ -1017,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)), } } @@ -1127,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); } @@ -1348,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; + } } } @@ -1654,6 +1695,12 @@ 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)); // Rows on threads: every pixel reads only `hmap` and `shape`. @@ -1674,7 +1721,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat 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; + 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 { @@ -1683,7 +1730,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat } }, ); - (apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour)) + (hi, sh) }; let maps = match g.paint { BevelPaint::Inner | BevelPaint::Outer => { @@ -1812,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, @@ -1911,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 @@ -2157,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 { @@ -2219,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) + }; } } } @@ -2521,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), @@ -3301,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 { @@ -3322,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 2ed1873a9d..e74cfd6d2f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -399,6 +399,7 @@ 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(), @@ -689,11 +690,33 @@ fn render_reduced_with(doc: &Document, b: Rect, w: u32, h: u32, damage: Option 0.0 { [p[0] / a, p[1] / a, p[2] / a, a / n] } else { [0.0; 4] }; + 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 } } @@ -749,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`]). @@ -820,6 +846,7 @@ 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), } @@ -962,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 { @@ -1047,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 @@ -1141,8 +1200,9 @@ pub fn relayout_damage(a: &Document, b: &Document) -> Rect { out } -/// 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 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; } @@ -1152,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> { @@ -1438,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, } } @@ -1450,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. @@ -1748,18 +1925,23 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer 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); - 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); @@ -1787,6 +1969,27 @@ 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) { @@ -1973,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) { @@ -1981,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) } @@ -1989,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`]). @@ -2102,6 +2339,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { 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; @@ -2148,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]; @@ -2213,6 +2468,8 @@ fn blend_span_g(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: B } } +#[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/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 99823457fa..8f111269a8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -505,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); @@ -637,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]); @@ -1063,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, }) } @@ -1239,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] @@ -1339,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), @@ -1355,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); @@ -1515,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), @@ -1665,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] @@ -1755,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), @@ -1804,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)); @@ -2734,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; @@ -2764,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), @@ -3101,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] @@ -3624,3 +4074,126 @@ fn fully_masked_adjustment_tiles_preserve_output_and_skip_tone_work() { } } } + +#[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/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 268b0b6b43..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 { @@ -933,7 +938,8 @@ pub struct FileEncoding { #[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)` - /// side by side, `Some(1)` stacked, so saving a stereo PNG keeps the mark (#6063). + /// 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, } 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/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 6b710eab93..ec5b4ac075 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -42,6 +42,7 @@ 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). 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 7e3b05b44c..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() diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 1dc0c88656..312caedc32 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -731,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 92b630aa44..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 } diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index f9138e8b65..c1184f2a4c 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -535,3 +535,20 @@ fn count_add_keeps_first_group_creation_and_targets_the_requested_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 4bc4a829b2..d9d002ee84 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -183,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 @@ -426,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)?; @@ -440,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; diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index f3053e21fd..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); @@ -612,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 811d1dbc5e..1a0fa60adb 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,69 @@ macro_rules! ev { Some(m) => step($id, Value::Object(m)), None => Mapped::Unsupported { harmless: false }, }, - to: |_, p| if pixel_target(p) { fields_to(p, $fields, desc("null")) } else { None }, + 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. @@ -992,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` @@ -1013,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); } @@ -1096,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 }, } @@ -1113,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)); @@ -1124,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")); @@ -1135,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)))) } @@ -1153,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)); } @@ -1162,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"); @@ -1173,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)) @@ -1272,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 }; @@ -1313,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, @@ -1330,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") }])), @@ -1353,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)); @@ -1381,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") }]); @@ -1416,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) @@ -1445,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)) @@ -1479,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) }])) + } }) } @@ -1600,7 +1891,9 @@ fn close_to(_: &str, p: &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") { @@ -1627,8 +1920,15 @@ fn transform_from(d: &Descriptor) -> Mapped { // 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 { @@ -1637,7 +1937,7 @@ fn transform_from(d: &Descriptor) -> Mapped { 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)) @@ -1647,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, }; @@ -1736,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; + } // The Apply Image dialog sends flat `sourceLayer`/`sourceChannel`/`sourceInvert` fields; read // them as the command does, or the export would quietly reset them to the merged composite. let source = crate::channel_cmds::source_spec(p, "source", "source").unwrap_or(json!({})); @@ -1838,7 +2159,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, }) @@ -1862,11 +2183,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 }; } @@ -1902,6 +2233,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)) @@ -1928,7 +2261,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 { @@ -1965,8 +2298,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") }])) } @@ -1981,6 +2319,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})), @@ -2066,6 +2418,14 @@ fn fill_to(id: &str, p: &Value) -> Option { d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); // `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 `. @@ -2077,7 +2437,9 @@ fn fill_to(id: &str, p: &Value) -> Option { 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)); } @@ -2088,7 +2450,12 @@ 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)); } @@ -2118,6 +2485,16 @@ fn bucket_color(v: Option<&Value>) -> Option> { 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)), @@ -2449,14 +2826,32 @@ fn shadows_highlights_to(_: &str, p: &Value) -> Option { } 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"); - if let Some(pc) = jnum(p, "percent") { + // `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") { @@ -2471,7 +2866,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()); @@ -2489,6 +2890,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)))); @@ -2496,7 +2902,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 { @@ -2611,14 +3020,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 }, @@ -2630,6 +3043,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] = &[ @@ -2670,6 +3098,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. @@ -2696,6 +3292,14 @@ 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!("", "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)]), @@ -2711,21 +3315,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", @@ -2733,8 +3331,8 @@ 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 { @@ -2755,47 +3353,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: "", - 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", &[]), @@ -2895,6 +3464,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", ], @@ -2913,6 +3486,7 @@ const EVENTS: &[Event] = &[ "select.rect", "select.lasso", "select.editInQuickMaskMode", + "tools.setColors", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2938,6 +3512,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, @@ -2976,11 +3555,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", @@ -2999,17 +3591,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", &[]), @@ -3049,6 +3666,7 @@ 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 }, ]; @@ -3057,7 +3675,8 @@ const EVENTS: &[Event] = &[ /// 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")]; +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(); @@ -3098,7 +3717,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 @@ -3133,7 +3752,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) } }; @@ -3184,21 +3803,44 @@ 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 @@ -3241,17 +3883,27 @@ pub fn export( (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); } @@ -3261,12 +3913,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 { @@ -3294,19 +3968,18 @@ 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()); // 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.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); added.push(a); @@ -3470,3 +4143,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 bbcef0ad54..d7d8e87f88 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(); @@ -163,6 +262,18 @@ fn load_renames_each_play_target_once() { 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(); @@ -199,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![ @@ -206,6 +408,7 @@ 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"})), @@ -236,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"})), @@ -275,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!({})), @@ -337,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})), @@ -359,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")); @@ -437,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", @@ -465,7 +696,10 @@ 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}`" + ); } } @@ -518,6 +752,93 @@ fn bucket_keeps_an_explicit_color_through_atn() { } } +/// `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"), + } +} + #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a @@ -565,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(); @@ -871,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})); @@ -902,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), @@ -1070,17 +1638,19 @@ fn remove_white_clears_the_targeted_layer_not_the_converted_background() { 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` and -/// `BlnW` ("the command is not currently available"), while the names `boxblur`, -/// `surfaceBlur`, `flattenImage`, `blackAndWhite` and the code `FltI` run. +/// 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")); @@ -1127,7 +1697,7 @@ fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import( // 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"] { + 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)); } @@ -1141,6 +1711,72 @@ fn events_photoshop_only_runs_by_name_export_by_name_and_old_codes_still_import( } } +/// #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] @@ -1267,6 +1903,156 @@ fn save_while_recording_includes_pending_steps() { 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] @@ -1303,6 +2089,108 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { 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] @@ -1322,6 +2210,28 @@ fn arbitrary_rotation_keeps_interpolation_through_atn() { } } +/// 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] @@ -1343,6 +2253,24 @@ fn add_noise_with_mask_target_is_not_exported_as_pixel_filter() { } } +/// 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] @@ -1407,3 +2335,373 @@ fn apply_image_flat_source_fields_survive_a_strict_export() { assert!(export_item("image.applyImage", &json!({"source": {"document": "Other", "channel": "red"}}), "S").is_none()); assert!(export_item("image.applyImage", &json!({"sourceDocument": "Other"}), "S").is_none()); } + +/// 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 bb795b10c8..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 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. @@ -1119,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 @@ -1201,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 8d9efca083..87504dc0e9 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -513,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)] { @@ -755,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); @@ -1187,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/canvas_geom.rs b/crates/engine/src/canvas_geom.rs index 8de32f4327..3233135132 100644 --- a/crates/engine/src/canvas_geom.rs +++ b/crates/engine/src/canvas_geom.rs @@ -77,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. diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 91dc94c7e4..bda9eae0b0 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.") @@ -1427,6 +1427,7 @@ fn options(s: &mut Session, p: &Value) -> Result { 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(); @@ -1495,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)) } @@ -1671,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()))?; @@ -1892,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/commands.rs b/crates/engine/src/commands.rs index 9b4d4a7472..2b65788fcc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -120,7 +120,7 @@ fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result 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; @@ -403,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)?; @@ -411,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"))?), @@ -427,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(()) } @@ -436,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) @@ -449,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. @@ -531,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}; @@ -567,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 })) } @@ -584,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 ), @@ -619,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(()) @@ -754,8 +777,9 @@ fn build() -> Vec { return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers"); } let id = layer_param(s, p)?; + 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); } @@ -1031,10 +1055,11 @@ fn build() -> Vec { has_doc, |s, p| { 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) @@ -1052,9 +1077,10 @@ fn build() -> Vec { |s, p| { 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 = f32_or(p, "angle", 90.0); + 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]))], @@ -1065,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) @@ -1118,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 ), @@ -1246,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, }, @@ -1437,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()); @@ -1498,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()); @@ -1508,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()); @@ -1584,6 +1635,7 @@ pub fn adjustment_kind(a: &Adjustment) -> &'static str { Adjustment::GradientMap { .. } => "gradientMap", Adjustment::SelectiveColor { .. } => "selectiveColor", Adjustment::ColorLookup { .. } => "colorLookup", + Adjustment::Unsupported { .. } => "unsupported", _ => "invert", } } @@ -1699,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 43a8ec12f0..db239c328f 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -251,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")?; @@ -444,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 d4857ad025..1762bce0a2 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -348,6 +348,46 @@ fn clearing_warnings_of_an_unknown_comp_is_an_error() { 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] 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/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 393e85a062..1881860ae1 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -191,6 +191,36 @@ fn smart_filters_reread_only_followed_files() { 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"); 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 aa2ce627f7..29ce479c51 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -456,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 { @@ -1109,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(); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index b620230a61..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,13 +68,27 @@ 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. @@ -75,7 +102,8 @@ pub(crate) fn after_command(s: &mut Session, id: &str) { 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). @@ -144,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)` @@ -208,9 +252,9 @@ 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())); } @@ -219,8 +263,7 @@ fn fade(s: &mut Session, p: &Value) -> Result { 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(()) @@ -1190,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(()) @@ -1214,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(()) })?; diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 16e98b8719..21d667ecce 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -72,7 +72,7 @@ fn fade_uniform_layer_mask_invert_at_all_depths() { 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| f.mask), "depth {depth}: Fade must target 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(); @@ -89,6 +89,69 @@ fn fade_uniform_layer_mask_invert_at_all_depths() { } } +/// #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); 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/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 072a3d4417..4e2a265ab9 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -131,10 +131,15 @@ 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 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]]); @@ -278,57 +283,6 @@ 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. @@ -933,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 @@ -1057,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)), ] } @@ -1541,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); diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 3cec3e4492..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 @@ -268,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. @@ -285,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, DPX -/// bits, DDS compression and mipmaps, 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 @@ -301,6 +353,8 @@ 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, @@ -315,9 +369,11 @@ impl Default for SaveOpts { fn default() -> Self { 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, @@ -326,17 +382,20 @@ impl Default for SaveOpts { } 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, @@ -369,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> { @@ -423,14 +516,20 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // 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}"))) } @@ -505,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; @@ -558,6 +657,19 @@ pub(crate) 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 { @@ -567,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; @@ -608,8 +721,8 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec, linked: Option 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). @@ -795,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 { @@ -802,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 @@ -963,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})); @@ -999,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)) { @@ -1129,19 +1300,37 @@ 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" { 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) } /// The extension a batch output takes with Format "Same": the input's own, except for formats @@ -1169,7 +1358,14 @@ fn same_format_takes_the_file_names_extension() { assert_eq!(same_format("/in/.hidden"), "png"); } -fn process_outputs<'a>(outputs: impl IntoIterator, save: SaveOpts, f: &dyn Fn(&mut Session) -> Result<()>) -> Value { +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(); @@ -1178,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 { @@ -1217,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(); @@ -1248,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) } @@ -1276,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) } @@ -1301,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); @@ -1712,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,"overwrite":bool=false (replace the document's own file),"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),"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)}"##, + 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 ), @@ -1775,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),"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)"##, + 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 ), @@ -1784,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),"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)"##, + 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 ), @@ -1812,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),"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}"##, + 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 ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 9b5ec43a78..3115d713b6 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -92,6 +92,61 @@ fn batch_same_format_saves_camera_raws_as_psd() { } } +/// 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"); @@ -309,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] @@ -568,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<()> { @@ -1428,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 c35ed9774b..574241681a 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -112,7 +112,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { "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")?) @@ -494,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); @@ -592,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 dd0ff8c803..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)? { @@ -297,9 +301,24 @@ pub(crate) fn run_filter(s: &mut Session, id: &str, p: &Value) -> Result 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())), @@ -309,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) }, @@ -318,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. -pub(crate) 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 { @@ -833,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_ext.rs b/crates/engine/src/filters_ext.rs index 0425e1932a..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), @@ -401,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 @@ -450,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())), @@ -465,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}")))?; @@ -628,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…", 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 356d2252db..138be5bbeb 100644 --- a/crates/engine/src/gradient_fill_cmds.rs +++ b/crates/engine/src/gradient_fill_cmds.rs @@ -37,6 +37,14 @@ 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()) } @@ -177,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/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 6d5342b931..d52522ca35 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -243,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(()), @@ -272,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 { @@ -287,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); } @@ -304,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 @@ -359,14 +393,8 @@ 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); @@ -375,6 +403,48 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi 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] = [ @@ -451,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(()) })?; @@ -517,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; @@ -544,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. @@ -685,7 +764,7 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar } 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)?; @@ -724,11 +803,11 @@ fn turned_crop_source(old: Size, new: Size, angle: f64, target: Rect) -> Rect { 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, }; @@ -772,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)?; @@ -803,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(); @@ -1118,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. @@ -1288,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 { @@ -1548,6 +1709,32 @@ 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. @@ -1678,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 0ee0573170..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>, @@ -400,6 +405,8 @@ pub fn run( label: label.to_string(), params: Value::Null, journal: true, + recorded_by: None, + pre_selected: None, doc, snapshot, ctx, @@ -484,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 @@ -502,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. @@ -510,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}), @@ -524,19 +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, 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)); @@ -545,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})) }, ) } @@ -748,11 +777,32 @@ 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. @@ -780,6 +830,8 @@ 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)) @@ -788,7 +840,18 @@ impl Session { if let Some(before) = before { self.add_relayout_damage(&before); } - self.after_command(id, params, spec.journal, Some(nested_from)); + 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)) } } @@ -826,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 { @@ -843,9 +906,9 @@ impl Session { if !crate::brush_cmds::coalesce_journal(self, id, ¶ms) { let params = match id { _ if self.actions.recording.is_none() => params, - "layer.select" => crate::layer_multi_cmds::recorded_selection(self, 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), - _ => params, + _ => crate::layer_multi_cmds::recorded_step(self, id, params, pre_selected), }; self.journal.push((id.to_string(), params)); } @@ -868,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, layers, 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) => { @@ -916,7 +985,10 @@ 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. 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 ab25912296..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(()) })?; @@ -902,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()))?; @@ -1020,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 171034876e..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"); @@ -552,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 418b0051d0..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 @@ -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 })) @@ -1387,13 +1598,14 @@ pub fn delete_selected(s: &mut Session) -> Result { pub fn duplicate_selected(s: &mut Session, in_place: bool, label: &str) -> Result { let sel = selected(s); 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)?; @@ -1512,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(); @@ -1778,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. @@ -2286,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]); @@ -2294,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] @@ -2719,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); @@ -2729,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 8933f838b9..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; @@ -693,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") { @@ -792,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") { @@ -899,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 @@ -922,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. @@ -1002,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!( @@ -1028,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", @@ -1144,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"] { @@ -1205,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(); @@ -1328,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( @@ -1364,6 +1502,7 @@ mod tests { size: 3.0, position: StrokePosition::Outside, paint: FxPaint::Color(Color::BLACK), + overprint: false, }); for p in [ json!({}), @@ -1518,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"}}), @@ -1533,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 ba088c7086..c68469f40f 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -65,13 +65,7 @@ pub fn lens_params(cmd: &str, p: &Value, frame: Rect, info: Option<&exif::Camera "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 => { @@ -292,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; @@ -323,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))?; @@ -427,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!({}))?; } @@ -557,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 ba7556901b..64814126cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ 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; @@ -58,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; @@ -72,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; @@ -209,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). @@ -239,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 { @@ -270,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, @@ -282,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. @@ -298,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() } @@ -497,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>, @@ -565,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) { @@ -794,6 +831,7 @@ impl Session { 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 @@ -829,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(); @@ -846,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()) { @@ -877,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 @@ -927,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 { @@ -935,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 } @@ -957,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/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/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index c28b17689a..180f940e91 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -87,7 +87,7 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { 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 a = Affine { m: [cs, sn, -sn, cs, nx - (cs * cx - sn * cy), ny - (sn * cx + cs * cy)] }; - s.set_sampler_map(Affine::translate(0.5, 0.5).then(&a).then(&Affine::translate(-0.5, -0.5))); + 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; @@ -393,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, @@ -408,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 ---------- @@ -532,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 e6892680f7..84d816a349 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -344,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"); 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/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 0cd1f01604..fbff5c6e22 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -202,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)?; @@ -216,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)?; @@ -228,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})) } @@ -299,6 +320,28 @@ 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] @@ -328,6 +371,10 @@ mod tests { #[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]]}), @@ -345,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 c729afd0d5..222866fdd0 100644 --- a/crates/engine/src/photo_cmds.rs +++ b/crates/engine/src/photo_cmds.rs @@ -807,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!({ @@ -1094,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); 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 a2044f5e8c..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()))) @@ -86,7 +126,7 @@ fn run(s: &mut Session, p: &Value) -> Result { 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); + crate::filters::keep_alpha(before, surf, &|| false)?; } Ok(()) } diff --git a/crates/engine/src/plugin_cmds/tests.rs b/crates/engine/src/plugin_cmds/tests.rs index 39bb1acc7d..7c6ed635aa 100644 --- a/crates/engine/src/plugin_cmds/tests.rs +++ b/crates/engine/src/plugin_cmds/tests.rs @@ -155,6 +155,62 @@ 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. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ed8b7f148f..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 @@ -444,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, @@ -463,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 { @@ -470,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, @@ -481,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(), } } } @@ -512,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, @@ -571,9 +597,10 @@ 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, @@ -869,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 { @@ -881,6 +921,7 @@ impl Default for RawDefaults { open_as_smart_object: false, apply_auto_tone: false, open_in_camera_raw: true, + demosaic: RawDemosaic::Ahd, } } } @@ -931,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")] @@ -993,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, @@ -1002,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). @@ -1034,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", @@ -1049,11 +1126,9 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.allowScriptsToConnect", "plugIns.generatorEnabled", "type.textEngine", - "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", "rawDefaults.colorSpace", "rawDefaults.bitDepth", - "rawDefaults.resolution", "rawDefaults.sharpenFor", "rawDefaults.openAsSmartObject", "rawDefaults.applyAutoTone", @@ -1114,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, @@ -1141,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, @@ -1152,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, @@ -1481,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() => { @@ -1513,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"), @@ -1521,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`]). @@ -1732,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(); @@ -1743,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); diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 903a456d85..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()])]); } diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index 5e3e2cda95..14273c2bd9 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -92,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, diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 2a700aad99..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,7 +635,7 @@ 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 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); @@ -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, diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index e5c3415f7d..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; } diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index db719afb1d..89ba8a5855 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -766,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 @@ -775,21 +817,46 @@ 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 diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9b264f0611..a0395ff520 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -253,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. @@ -268,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(')'); @@ -294,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 { @@ -476,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 { @@ -490,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 }) } @@ -569,7 +707,49 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { Ok(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 @@ -587,20 +767,16 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spool // 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"); - // 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 - // below, 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); - if cfg!(windows) && !skip_native && 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) && !skip_native && 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); @@ -642,7 +818,7 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spool 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, "backend": backend, "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}), ) } @@ -695,6 +871,9 @@ 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| { @@ -1098,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?,"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}"##; + 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), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 38567ffb6d..43f49101a7 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -1,6 +1,68 @@ 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); @@ -869,3 +931,232 @@ fn windows_pdf_component_is_detected_by_loading_its_dll() { 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/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index e86a1a4d91..005377328f 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -27,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}; @@ -415,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 @@ -445,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 { @@ -461,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(); @@ -604,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)); @@ -673,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())) @@ -1093,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. @@ -1106,7 +1167,7 @@ pub struct LiveDab { effect: DabEffect, halo: i32, pre_surf: Surface, - work: Surface, + work: WorkPixels, id: Option, params: Value, opacity: f32, @@ -1146,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(); @@ -1160,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(), @@ -1200,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(); @@ -1212,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)); } @@ -1229,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 { @@ -1436,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 b4b71745ae..6f14ac8eb0 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -6,7 +6,7 @@ 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}; use photocraft_paint::{Stroke, StrokePoint}; @@ -24,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), @@ -144,9 +201,7 @@ fn paint_aligned( sel: Option<&Surface>, lock: bool, tile: &Tile, - phase: (f32, f32), - scale: f32, - angle: f32, + place: Placement, mode: BlendMode, cmd: &str, ) -> Result { @@ -154,35 +209,10 @@ fn paint_aligned( 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; diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index edbf33c317..7f60b72c16 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1187,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] @@ -1647,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, @@ -1865,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 57787b2e83..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(); @@ -447,6 +448,25 @@ mod tests { 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] fn bad_params_and_no_document_are_errors_not_panics() { let mut s = Session::new(); @@ -472,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 @@ -485,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 3ca10083b9..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; @@ -194,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 { @@ -229,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(); @@ -293,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); @@ -314,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), @@ -322,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. @@ -424,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, ""); @@ -458,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)? { @@ -761,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) @@ -771,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 { @@ -814,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 { @@ -1018,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]; @@ -1032,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 { @@ -1192,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 } @@ -1208,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 51d8d007f7..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] { @@ -1139,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 ef0e72f5db..78c8b9f2e8 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -95,8 +95,8 @@ fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> { /// 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(l: &photocraft_doc::Layer) -> Option<[f64; 4]> { - snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect) +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 { @@ -132,7 +132,14 @@ impl SnapTargets { if !l.visible || exclude.contains(&l.id) { continue; } - if let Some(r) = target_rect_of(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); } } @@ -389,4 +396,29 @@ mod tests { ); 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 85ac4078c7..76af35627a 100644 --- a/crates/engine/src/stamp_cmds.rs +++ b/crates/engine/src/stamp_cmds.rs @@ -121,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; @@ -134,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(()) @@ -324,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 0849cd941a..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(); @@ -1161,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(); @@ -1470,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] @@ -1612,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 716fc8b4c8..7893ac8d8f 100644 --- a/crates/engine/src/timeline_cmds.rs +++ b/crates/engine/src/timeline_cmds.rs @@ -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 2452e7443b..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_size, 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. -pub(crate) 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,30 +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. -#[cfg(not(test))] -const BUDGET: u64 = MAX_TRANSFORM_PIXELS; -#[cfg(test)] -const BUDGET: u64 = 1 << 20; - -/// 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<()> { - check_budget_with_limit(src, h, what, BUDGET) +/// Set the memory (bytes) one transform's result may take (see [`budget`]). +pub fn set_budget(bytes: u64) { + BUDGET.store(bytes, Ordering::Relaxed); } -fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> { +/// 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))] +fn active_budget() -> u64 { + budget() +} + +/// 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(()) }; - 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)); + // 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(()) } @@ -300,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())?; } } _ => { @@ -313,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())?; } } } @@ -325,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(()) @@ -333,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) } @@ -511,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)) { @@ -661,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)?, @@ -734,29 +887,78 @@ 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", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused"); + 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, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]); - assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok()); + 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. @@ -777,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)); @@ -803,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(()) }) @@ -814,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"); @@ -826,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(); @@ -835,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}"); @@ -1498,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/type_cmds.rs b/crates/engine/src/type_cmds.rs index 030fe18510..820338bf2d 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -8,7 +8,7 @@ use std::sync::Arc; -use photocraft_color::Color; +use photocraft_color::{Color, PixelFormat}; use photocraft_doc::text::{ AntiAlias, Caps, CharStyle, FontBaseline, FontFeature, FontVariation, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape, @@ -108,7 +108,8 @@ pub fn check_kerning(p: &Value) -> std::result::Result<(), String> { } /// 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. +/// 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() { @@ -138,6 +139,11 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { _ => 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() { @@ -244,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) { @@ -365,12 +384,17 @@ 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) { - s.baseline = match v { - "superscript" => FontBaseline::Superscript, - "subscript" => FontBaseline::Subscript, - _ => FontBaseline::Normal, + let baseline = match v { + "superscript" => Some(FontBaseline::Superscript), + "subscript" => Some(FontBaseline::Subscript), + "normal" => Some(FontBaseline::Normal), + _ => None, }; - hit(true); + // 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 { @@ -579,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()))); } @@ -598,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 { @@ -610,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)); @@ -678,14 +709,14 @@ pub(crate) fn with_text_targets( 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)); } @@ -906,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). @@ -918,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; @@ -1034,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(()) @@ -1617,6 +1648,23 @@ mod tests { 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 91d75f033e..fd54db1fed 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -15,7 +15,8 @@ use serde_json::{Value, json}; 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})) } @@ -499,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; } } @@ -527,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); } } } @@ -585,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; @@ -606,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; } } @@ -699,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 687a56471a..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. @@ -438,3 +466,24 @@ fn camel_case_and_underscore_postscript_names_resolve_to_their_installed_faces() 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 93b4261a00..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; @@ -292,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 { @@ -303,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; } } @@ -517,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 }; @@ -538,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 0a49d780d5..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)) => { diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 15c14e5a1c..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`"))?; @@ -1385,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 }), }) } @@ -1421,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) @@ -1441,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()))?; @@ -1453,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; @@ -1699,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 @@ -1709,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 @@ -1848,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 a2e8047115..a204c8f8fc 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -803,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); @@ -876,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 @@ -1005,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 0d27d3c827..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 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 8e2ab3c4fd..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))); @@ -127,7 +144,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, 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())), @@ -154,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); @@ -165,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 @@ -173,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 + } } }; } @@ -189,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)?, }; } @@ -324,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 { @@ -332,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})) @@ -350,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)] @@ -630,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(); @@ -894,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 755ba07320..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 { @@ -732,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 { @@ -964,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, exif, 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 { @@ -1097,8 +1086,25 @@ fn quick_export(s: &mut Session, p: &Value) -> Result { 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( @@ -1307,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 @@ -1327,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:?}"))); } @@ -1548,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 ), diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 5791e5f27c..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); @@ -466,6 +479,33 @@ fn quick_export_never_replaces_the_documents_own_file_unasked() { 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"); @@ -785,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. @@ -809,7 +864,10 @@ 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"] @@ -977,3 +1035,38 @@ fn web_exif_that_does_not_fit_is_reported_and_bad_exif_is_harmless() { 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/camera_raw_exposure_clamp.rs b/crates/engine/tests/it/camera_raw_exposure_clamp.rs similarity index 100% rename from crates/engine/tests/camera_raw_exposure_clamp.rs rename to crates/engine/tests/it/camera_raw_exposure_clamp.rs 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/duplicate_slices.rs b/crates/engine/tests/it/duplicate_slices.rs similarity index 100% rename from crates/engine/tests/duplicate_slices.rs rename to crates/engine/tests/it/duplicate_slices.rs 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/finite_command_params.rs b/crates/engine/tests/it/finite_command_params.rs similarity index 100% rename from crates/engine/tests/finite_command_params.rs rename to crates/engine/tests/it/finite_command_params.rs 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/gradient_overlay_strokes.rs b/crates/engine/tests/it/gradient_overlay_strokes.rs similarity index 100% rename from crates/engine/tests/gradient_overlay_strokes.rs rename to crates/engine/tests/it/gradient_overlay_strokes.rs 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/nonfinite_style.rs b/crates/engine/tests/it/nonfinite_style.rs similarity index 100% rename from crates/engine/tests/nonfinite_style.rs rename to crates/engine/tests/it/nonfinite_style.rs 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/transform_budget_unclamped.rs b/crates/engine/tests/it/transform_budget_unclamped.rs similarity index 51% rename from crates/engine/tests/transform_budget_unclamped.rs rename to crates/engine/tests/it/transform_budget_unclamped.rs index c603bee7b5..267667f33b 100644 --- a/crates/engine/tests/transform_budget_unclamped.rs +++ b/crates/engine/tests/it/transform_budget_unclamped.rs @@ -1,4 +1,5 @@ -//! #6430: integration tests use the production pixel budget, not the unit tests' reduced one. +//! #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}; @@ -16,11 +17,13 @@ fn painted() -> Session { 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.contains("limit"), "{error}"); + 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)); @@ -37,6 +40,7 @@ fn extreme_layer_scales_are_refused_before_a_clipped_strip_is_warped() { } } +/// 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(); @@ -47,3 +51,24 @@ fn extreme_selection_scales_are_refused_without_losing_the_selection() { } } } + +/// #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/src/atomic.rs b/crates/format/src/atomic.rs index 3a4aeca6c0..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 @@ -59,7 +60,7 @@ 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 metadata a replacement should keep — mode, extended attributes, ACL — from the /// file about to be replaced onto the temporary file. Best effort; see [`meta`]. @@ -100,12 +101,27 @@ 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(()) @@ -172,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) } @@ -238,8 +254,10 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & 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(()) } @@ -316,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/tests.rs b/crates/format/src/atomic/tests.rs index d4381705a0..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,7 +69,12 @@ 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_metadata(&self, from: &Path, to: &Path) -> io::Result<()> { @@ -76,6 +84,39 @@ impl AtomicFs for Faulty { 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); } 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 7da5dd6b53..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") @@ -304,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)); @@ -330,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 } @@ -365,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), + } } } @@ -525,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))?; @@ -562,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()); @@ -729,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 { @@ -777,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)) @@ -805,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. @@ -862,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 { @@ -876,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)?; - 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?, - } + #[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 _ = 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. @@ -945,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() { @@ -1011,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 99% rename from crates/format/tests/corruption.rs rename to crates/format/tests/it/corruption.rs index 727b37cb70..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::*; 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/float_roundtrip.rs b/crates/format/tests/it/float_roundtrip.rs similarity index 100% rename from crates/format/tests/float_roundtrip.rs rename to crates/format/tests/it/float_roundtrip.rs 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 85% rename from crates/format/tests/roundtrip.rs rename to crates/format/tests/it/roundtrip.rs index 5a4fe87b2d..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}; @@ -511,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); } @@ -523,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 @@ -602,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), @@ -753,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); @@ -774,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 1b62e2d46d..a6c0c63579 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -675,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) { @@ -935,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); } @@ -950,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); @@ -957,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); } @@ -1118,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 @@ -1136,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; @@ -1185,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 @@ -1227,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); } @@ -1280,7 +1319,14 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4 { 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; @@ -1435,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; @@ -1443,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 994eb5e149..db204a3bf4 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -954,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 334944daad..4d3fd8f93a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -478,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). @@ -589,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. @@ -720,6 +743,8 @@ impl Compositor { stamp: 0, staged: 0, recorded: 0, + layer_alloc: None, + admit_freed: 0, acc_format, effect_maps: map_error.is_none(), cmyk: 0, @@ -1162,6 +1187,8 @@ 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. @@ -1225,9 +1252,45 @@ impl Compositor { 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(), self.timer.as_mut(), &mut last_submit); self.staged = 0; @@ -1280,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 { @@ -1321,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); @@ -1420,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 { @@ -1638,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(); @@ -1651,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, @@ -1660,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 { @@ -2423,43 +2539,91 @@ 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()); } @@ -2802,6 +2966,21 @@ mod tests { } } + #[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); @@ -3005,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 6cb44b9200..815b55517d 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -1329,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; @@ -1701,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; @@ -1757,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). 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 91% rename from crates/gpu/tests/parity.rs rename to crates/gpu/tests/it/parity.rs index 5de36b1dfe..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, @@ -220,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. @@ -688,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 }; @@ -707,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:?}")); @@ -771,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)]); @@ -891,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)), ]; @@ -1448,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), @@ -1458,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 { @@ -1504,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)]), @@ -1529,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))]), @@ -1570,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))))]), @@ -1930,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![ @@ -1968,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 { @@ -2323,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; @@ -2743,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()); @@ -2790,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/timing.rs b/crates/gpu/tests/it/timing.rs similarity index 96% rename from crates/gpu/tests/timing.rs rename to crates/gpu/tests/it/timing.rs index 24c2057253..9761a1d90d 100644 --- a/crates/gpu/tests/timing.rs +++ b/crates/gpu/tests/it/timing.rs @@ -111,6 +111,7 @@ fn measure(device: &wgpu::Device, queue: &wgpu::Queue, comp: &mut Compositor, do #[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); @@ -164,6 +165,7 @@ fn gpu_time_tracks_execution_not_submission() { #[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"); @@ -175,6 +177,7 @@ fn an_unmeasured_compositor_reports_nothing() { #[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. @@ -208,6 +211,7 @@ fn a_refresh_that_submits_several_times_is_one_measurement() { #[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 @@ -232,6 +236,7 @@ fn refreshes_faster_than_the_readback_stay_valid() { /// 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"); diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/it/uniform_limits.rs similarity index 99% rename from crates/gpu/tests/uniform_limits.rs rename to crates/gpu/tests/it/uniform_limits.rs index 994dbd7508..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)) 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/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/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 46335deafb..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 @@ -399,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`). @@ -439,9 +446,9 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option { 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(); } @@ -960,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}; 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/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 c9e3a410ae..7786c28351 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -52,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)?; @@ -814,15 +857,16 @@ 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) != table.transparent.map(usize::from)).map(|(i, c)| (i as u8, c)).unzip(); + 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)?; photocraft_compose::render_bands(doc, doc.bounds(), 0, |band| -> Result<(), IoError> { @@ -831,12 +875,20 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> 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 PNG palette has no opaque entry; add an opaque colour or convert to RGB".into()))?, + .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, opts.encode.png_stereo)?; @@ -862,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/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/lib.rs b/crates/io/src/lib.rs index 946abfbc75..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`. @@ -47,6 +49,7 @@ 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; @@ -54,8 +57,10 @@ 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; @@ -68,6 +73,7 @@ mod text_import; pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +mod xcf; use photocraft_codecs::{CodecError, EncodeOptions}; pub use photocraft_codecs::{DdsCompression, DdsOptions, DpxBits}; @@ -76,8 +82,8 @@ 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}; @@ -94,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), @@ -221,6 +230,8 @@ pub struct ExportOptions { /// 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 { @@ -234,6 +245,7 @@ impl Default for ExportOptions { image_previews: true, psd_maximize_compatibility: true, document_path: None, + pcraft_cache: None, } } } @@ -266,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 { @@ -296,35 +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) { 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); @@ -337,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) } @@ -429,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); } @@ -444,7 +515,11 @@ 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 { @@ -566,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() { @@ -576,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 c145b8b468..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; diff --git a/crates/io/src/palette_map.rs b/crates/io/src/palette_map.rs index b268efcb9c..45b8168f1c 100644 --- a/crates/io/src/palette_map.rs +++ b/crates/io/src/palette_map.rs @@ -35,7 +35,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil _ => return None, }; let mut warnings = Vec::new(); - if doc.layers.len() > 1 { + 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; @@ -69,6 +69,28 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> Option<(PsdFil 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]; @@ -124,3 +146,144 @@ fn bits(doc: &Document) -> Vec { }); 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/psd_export.rs b/crates/io/src/psd_export.rs index 9ad93bdf66..90a5d86ce9 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -455,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; @@ -607,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())); @@ -714,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()); @@ -1546,13 +1578,14 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c return file; } let fmt = doc.pixel_format(); - // A Duotone keeps the inks it was imported with (their Color Mode Data is undocumented and - // kept verbatim) over its grey plate; Photoshop has no 32-bit Duotone. - let duotone_inks = doc + // 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() - .and_then(|d| d.psd_raw.as_ref()) - .filter(|_| doc.mode == ColorMode::Duotone && fmt.mode == ColorMode::Grayscale && fmt.sample != SampleType::F32); + .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; @@ -1661,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 @@ -1851,7 +1885,7 @@ 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 = match duotone_inks { - Some(raw) => raw.to_vec(), + Some(raw) => raw, None => photocraft_psd::hdr::color_mode_data_for_depth(header.depth), }; let file = PsdFile { @@ -1872,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()) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0eca6d704f..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() } @@ -1008,10 +1077,17 @@ pub(crate) fn psd_to_document_decoded( 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"); } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index aba70c21e7..04158deddc 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -14,6 +14,7 @@ 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::{EXTENSIONS, Tone, retone}; @@ -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 57f8c5d7da..78b2d79201 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -20,7 +20,9 @@ //! 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}; @@ -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(); @@ -1148,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})), @@ -1155,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; @@ -1173,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") @@ -1196,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 = @@ -1774,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 8afefb733d..ac1bd27ffa 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -65,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 @@ -780,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. @@ -828,6 +950,53 @@ 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(""); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 91011c6eb1..6caf335768 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -223,6 +223,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr 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 diff --git a/crates/io/src/vector_map.rs b/crates/io/src/vector_map.rs index 3bd127445d..bb830dbd68 100644 --- a/crates/io/src/vector_map.rs +++ b/crates/io/src/vector_map.rs @@ -671,7 +671,7 @@ mod tests { let b = vogk_bytes(&s, 72.0).unwrap(); assert_eq!(live_from_vogk(&b), Some(s)); } - assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0 }, 72.0).is_none()); + assert!(vogk_bytes(&LiveShape::Polygon { rect: [0.0; 4], sides: 5, star_ratio: 1.0, corner_radius: 0.0 }, 72.0).is_none()); } #[test] @@ -699,7 +699,7 @@ mod tests { // Edit: path changes → vmsk regenerated, live dropped when polygon. let mut edited = sh.clone(); edited.path.subpaths.pop(); - edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0 }); + edited.live = Some(LiveShape::Polygon { rect: [0.0; 4], sides: 3, star_ratio: 1.0, corner_radius: 0.0 }); shape_blocks(&edited, &mut raw, 64, 64, 72.0); assert!(!raw.iter().any(|(k, _)| k == b"vogk")); let (p, _) = path_from_vmsk(&raw[0].1, 64, 64).unwrap(); diff --git a/crates/io/src/xcf.rs b/crates/io/src/xcf.rs new file mode 100644 index 0000000000..d5cda4aa4d --- /dev/null +++ b/crates/io/src/xcf.rs @@ -0,0 +1,1053 @@ +//! GIMP XCF (`.xcf`, `.xcf.gz`) read. Layout from the public specification +//! (https://developer.gimp.org/core/standards/xcf/, versions 0 to 26): a header, image +//! properties, then pointers to layer, channel and path structures; each drawable's pixels sit in +//! 64×64 tiles, uncompressed, RLE-coded or zlib-coded. +//! +//! Read: pixel layers at any position and size, groups (nested, pass-through), names, opacity, +//! visibility, offsets, layer masks (applied or disabled), locks, colour tags, blend modes (the +//! legacy and the 2.10 sets; those without a Photoshop equivalent open as Normal with a warning), +//! 8/16/32-bit integer and 16/32/64-bit float precisions (linear precisions get a linear-light +//! profile), the embedded ICC profile, resolution, guides, the comment, extra channels and the +//! saved selection; indexed images open as RGB. Text, vector and link layers open as their pixels; +//! non-destructive layer effects, paths and floating selections are reported, not applied. +//! Write: not supported (GIMP's own format; `.pcraft` keeps everything PhotoCraft holds). +//! +//! The reader is written from the specification and from files GIMP 3.2 writes; no GIMP code. + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::io::Read; +use std::sync::Arc; + +use photocraft_codecs::f16; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, LabelColor, Layer, LayerContent, LayerMask}; +use photocraft_geom::{Rect, Size}; +use photocraft_raster::{Interrupt, Surface}; + +use crate::ora::Warnings; +use crate::{ImportResult, IoError}; + +type Result = std::result::Result; + +const MAGIC: &[u8] = b"gimp xcf "; +/// Canvas side limit (Photoshop's PSB limit). +pub(crate) const MAX_SIDE: u32 = 300_000; +/// Decoded pixels across every drawable are capped, as for OpenRaster and PDN. +pub(crate) const MAX_PIXEL_BYTES: usize = 1 << 30; +/// Layers and groups in one document. +pub(crate) const MAX_LAYERS: usize = 8192; +/// Extra channels in one document. +const MAX_CHANNELS: usize = 1024; +/// Group nesting. +const MAX_DEPTH: usize = 64; +/// Total copied layer metadata (names and item paths). A single reused record with a large +/// name is bounded by MAX_STRING, but repeated layer offsets referencing it must not multiply +/// into unbounded retained metadata. +const MAX_METADATA_BYTES: usize = 32 << 20; +/// A string field (layer names, parasite names). +const MAX_STRING: usize = 1 << 20; +/// A property payload or a parasite that is read into memory. +const MAX_PAYLOAD: usize = 64 << 20; +/// The largest file a gzipped XCF may inflate to. +const MAX_INFLATED: usize = 1 << 30; +/// Highest version this reader was written against; newer files still open (unknown properties +/// are skipped by their length), with a warning. +const KNOWN_VERSION: u32 = 26; +const TILE: u32 = 64; + +fn invalid(message: impl Into) -> IoError { + IoError::Xcf(message.into()) +} + +/// `true` for an XCF file (by its signature). +pub(crate) fn is_xcf(bytes: &[u8]) -> bool { + bytes.starts_with(MAGIC) +} + +/// `true` for a gzip stream named `.xcf.gz` / `.xcfgz` (GIMP writes and reads compressed XCF). +pub(crate) fn is_gzipped_xcf(name: &str, bytes: &[u8]) -> bool { + let lower = name.rsplit(['/', '\\']).next().unwrap_or(name).to_ascii_lowercase(); + bytes.starts_with(&[0x1f, 0x8b]) && (lower.ends_with(".xcf.gz") || lower.ends_with(".xcfgz")) +} + +/// Inflates a gzipped XCF, bounded. +pub(crate) fn gunzip(bytes: &[u8]) -> Result> { + let mut out = Vec::new(); + let mut dec = flate2::read::GzDecoder::new(bytes).take(MAX_INFLATED as u64 + 1); + dec.read_to_end(&mut out).map_err(|e| invalid(format!("gzip: {e}")))?; + if out.len() > MAX_INFLATED { + return Err(invalid("the compressed file inflates past the 1 GiB limit")); + } + Ok(out) +} + +/// How samples are stored: bytes per sample, whether they are floats, and whether the values are +/// linear light (else gamma-encoded, i.e. the profile's own curve). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Precision { + bytes: usize, + float: bool, + linear: bool, +} + +impl Precision { + const GAMMA8: Precision = Precision { bytes: 1, float: false, linear: false }; + + fn from_header(version: u32, code: u32) -> Result { + let p = |bytes, float, linear| Ok(Precision { bytes, float, linear }); + match version { + 0..=3 => Ok(Self::GAMMA8), + 4 => match code { + 0 => p(1, false, false), + 1 => p(2, false, false), + 2 => p(4, false, true), + 3 => p(2, true, true), + 4 => p(4, true, true), + c => Err(invalid(format!("unknown precision {c} in an XCF version 4 file"))), + }, + 5 | 6 => match code { + 100 => p(1, false, true), + 150 => p(1, false, false), + 200 => p(2, false, true), + 250 => p(2, false, false), + 300 => p(4, false, true), + 350 => p(4, false, false), + 400 => p(2, true, true), + 450 => p(2, true, false), + 500 => p(4, true, true), + 550 => p(4, true, false), + c => Err(invalid(format!("unknown precision {c} in an XCF version {version} file"))), + }, + _ => match code { + 100 => p(1, false, true), + 150 => p(1, false, false), + 200 => p(2, false, true), + 250 => p(2, false, false), + 300 => p(4, false, true), + 350 => p(4, false, false), + 500 => p(2, true, true), + 550 => p(2, true, false), + 600 => p(4, true, true), + 650 => p(4, true, false), + 700 => p(8, true, true), + 750 => p(8, true, false), + c => Err(invalid(format!("unknown precision {c}"))), + }, + } + } + + /// The document depth the samples open at. + fn depth(self) -> SampleType { + match (self.float, self.bytes) { + (false, 1) => SampleType::U8, + (false, _) => SampleType::U16, + (true, _) => SampleType::F32, + } + } + + /// One sample, big-endian, as a normalised float. + fn sample(self, b: &[u8]) -> f32 { + match (self.float, self.bytes, b) { + (false, 1, [v]) => f32::from(*v) / 255.0, + (false, 2, [a, b]) => f32::from(u16::from_be_bytes([*a, *b])) / 65535.0, + (false, 4, [a, b, c, d]) => (u32::from_be_bytes([*a, *b, *c, *d]) as f64 / f64::from(u32::MAX)) as f32, + (true, 2, [a, b]) => f16::from_bits(u16::from_be_bytes([*a, *b])).to_f32(), + (true, 4, [a, b, c, d]) => f32::from_be_bytes([*a, *b, *c, *d]), + (true, 8, [a, b, c, d, e, f, g, h]) => f64::from_be_bytes([*a, *b, *c, *d, *e, *f, *g, *h]) as f32, + _ => 0.0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Compression { + None, + Rle, + Zlib, +} + +/// Big-endian reads with bounds checks; every failure is an error, never a panic. +struct Cursor<'a> { + b: &'a [u8], + pos: usize, + /// XCF 11 and later store pointers as 64-bit. + wide: bool, +} + +impl<'a> Cursor<'a> { + fn at(&self, pos: usize) -> Cursor<'a> { + Cursor { b: self.b, pos, wide: self.wide } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.pos.checked_add(n).ok_or_else(|| invalid("offset overflow"))?; + let s = self.b.get(self.pos..end).ok_or_else(|| invalid(format!("the file ends early (wanted {n} bytes at {})", self.pos)))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self) -> Result { + let s = self.take(4)?; + Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]])) + } + + fn ptr(&mut self) -> Result { + if self.wide { + let s = self.take(8)?; + usize::try_from(u64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])).map_err(|_| invalid("pointer does not fit in memory")) + } else { + self.u32().map(|v| v as usize) + } + } + + /// A STRING: `n+1` then `n` bytes and a NUL (0 for the empty string). + fn string(&mut self) -> Result { + let n = self.u32()? as usize; + if n == 0 { + return Ok(String::new()); + } + if n > MAX_STRING { + return Err(invalid(format!("a string of {n} bytes exceeds the limit"))); + } + let s = self.take(n)?; + Ok(String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()) + } + + fn skip(&mut self, n: usize) -> Result<()> { + self.take(n).map(|_| ()) + } +} + +/// One property record: its type and payload. +struct Prop<'a> { + kind: u32, + data: &'a [u8], +} + +/// Reads a property list; `fixed` gives the native payload sizes the spec says GIMP trusts over +/// the length word. Ends at PROP_END (type 0) or at the end of the file. +fn props<'a>(c: &mut Cursor<'a>) -> Result>> { + let mut out = Vec::new(); + loop { + let kind = c.u32()?; + let len = c.u32()? as usize; + if kind == 0 { + return Ok(out); + } + // PROP_COLORMAP's length word was wrong in some releases (n+4 for 3n+4 bytes); its real + // size follows from the count. PROP_USER_UNIT's length is wrong in every release. + let len = match kind { + 1 => { + let n = c.at(c.pos).u32()? as usize; + 4usize.checked_add(n.checked_mul(3).ok_or_else(|| invalid("colormap overflow"))?).ok_or_else(|| invalid("colormap overflow"))? + } + 24 => user_unit_len(c)?, + _ => len, + }; + if len > MAX_PAYLOAD { + return Err(invalid(format!("property {kind} declares {len} bytes"))); + } + let data = c.take(len)?; + out.push(Prop { kind, data }); + if out.len() > 4096 { + return Err(invalid("too many properties")); + } + } +} + +/// The real size of a PROP_USER_UNIT payload: a float, a count and three (XCF 21+) or five +/// strings. Measured without consuming. +fn user_unit_len(c: &Cursor<'_>) -> Result { + let mut p = c.at(c.pos); + p.skip(8)?; + for _ in 0..3 { + p.string()?; + } + // The two name forms of older files, when present: another string pair before the next + // property. Decide by whether a plausible property header follows. + let three = p.pos - c.pos; + let mut q = p.at(p.pos); + let looks_like_prop = |c: &mut Cursor<'_>| c.u32().map(|k| k <= 64).unwrap_or(false); + if looks_like_prop(&mut q) { + return Ok(three); + } + p.string()?; + p.string()?; + Ok(p.pos - c.pos) +} + +fn be_u32(b: &[u8]) -> Option { + b.get(..4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) +} + +fn be_i32(b: &[u8]) -> Option { + be_u32(b).map(|v| v as i32) +} + +fn be_f32(b: &[u8]) -> Option { + be_u32(b).map(f32::from_bits) +} + +/// The parasites of a PROP_PARASITES payload as (name, data); a later duplicate wins. +fn parasites(data: &[u8]) -> Vec<(String, Vec)> { + let mut c = Cursor { b: data, pos: 0, wide: false }; + let mut out: Vec<(String, Vec)> = Vec::new(); + // Map each name to its output slot so duplicate replacement is O(1). The parasite + // records are file-controlled, so scanning `out` with iter_mut().find() per record is + // O(n^2) and lets a crafted payload with many distinct names stall import. The map + // preserves insertion order and last-value-wins behavior. + let mut index: HashMap = HashMap::new(); + while c.pos < data.len() { + let Ok(name) = c.string() else { break }; + let (Ok(_flags), Ok(len)) = (c.u32(), c.u32()) else { break }; + let Ok(payload) = c.take(len as usize) else { break }; + match index.get(&name) { + Some(&i) => out[i].1 = payload.to_vec(), + None => { + index.insert(name.clone(), out.len()); + out.push((name, payload.to_vec())); + } + } + } + out +} + +/// What one tile's bytes decode to, for a drawable of `bpp` bytes per pixel. +fn decode_tile(c: &Cursor<'_>, at: usize, end: usize, compression: Compression, bpp: usize, pixels: usize) -> Result> { + let expected = pixels.checked_mul(bpp).ok_or_else(|| invalid("tile size overflow"))?; + let input = c.b.get(at..end.min(c.b.len())).ok_or_else(|| invalid("a tile pointer is outside the file"))?; + match compression { + Compression::None => input.get(..expected).map(<[u8]>::to_vec).ok_or_else(|| invalid("an uncompressed tile is cut short")), + Compression::Zlib => { + let mut out = Vec::with_capacity(expected); + flate2::read::ZlibDecoder::new(input).take(expected as u64).read_to_end(&mut out).map_err(|e| invalid(format!("zlib tile: {e}")))?; + if out.len() != expected { + return Err(invalid("a zlib tile is cut short")); + } + Ok(out) + } + Compression::Rle => rle_decode(input, bpp, pixels), + } +} + +/// RLE tile data: one run-length stream per byte position of the pixel, each of `pixels` bytes. +fn rle_decode(input: &[u8], bpp: usize, pixels: usize) -> Result> { + let mut out = vec![0u8; pixels * bpp]; + let mut pos = 0usize; + let mut plane = Vec::with_capacity(pixels); + for b in 0..bpp { + plane.clear(); + while plane.len() < pixels { + let op = *input.get(pos).ok_or_else(|| invalid("an RLE tile is cut short"))?; + pos += 1; + let (run, verbatim) = match op { + 0..=126 => (usize::from(op) + 1, false), + 127 => { + let (p, q) = (input.get(pos).copied(), input.get(pos + 1).copied()); + pos += 2; + ( + usize::from(p.ok_or_else(|| invalid("an RLE tile is cut short"))?) * 256 + + usize::from(q.ok_or_else(|| invalid("an RLE tile is cut short"))?), + false, + ) + } + 128 => { + let (p, q) = (input.get(pos).copied(), input.get(pos + 1).copied()); + pos += 2; + ( + usize::from(p.ok_or_else(|| invalid("an RLE tile is cut short"))?) * 256 + + usize::from(q.ok_or_else(|| invalid("an RLE tile is cut short"))?), + true, + ) + } + _ => (256 - usize::from(op), true), + }; + if plane.len() + run > pixels { + return Err(invalid("an RLE run crosses the end of its tile")); + } + if verbatim { + let data = input.get(pos..pos + run).ok_or_else(|| invalid("an RLE tile is cut short"))?; + pos += run; + plane.extend_from_slice(data); + } else { + let v = *input.get(pos).ok_or_else(|| invalid("an RLE tile is cut short"))?; + pos += 1; + plane.resize(plane.len() + run, v); + } + } + for (i, v) in plane.iter().enumerate() { + out[i * bpp + b] = *v; + } + } + Ok(out) +} + +/// Image-wide facts the drawables need. +struct Image<'a> { + c: Cursor<'a>, + version: u32, + precision: Precision, + compression: Compression, + base_type: u32, + colormap: Vec<[u8; 3]>, + budget: usize, + /// Remaining allowance for copied layer metadata (names, item paths). Repeated layer + /// offsets referencing one record with a large name would otherwise allocate an owned + /// copy per reference, multiplying a small file into gigabytes of retained metadata. + meta_budget: usize, + warnings: Warnings, + /// Layers GIMP composites with "Clip to backdrop" (its default for every mode but Normal). + clipped_to_backdrop: usize, +} + +/// Pixel data of a drawable: `channels` normalised samples per pixel, row-major. +struct Pixels { + width: u32, + height: u32, + channels: usize, + data: Vec, +} + +impl<'a> Image<'a> { + /// Reads a hierarchy (`hptr`): the first level's tiles into `channels` floats per pixel, + /// `source_channels` as stored (indexed images expand to RGB here). + fn hierarchy(&mut self, hptr: usize, source_channels: usize, ctl: &Interrupt<'_>) -> Result { + let mut c = self.c.at(hptr); + let (w, h, bpp) = (c.u32()?, c.u32()?, c.u32()? as usize); + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return Err(invalid(format!("drawable size {w}x{h} is out of range"))); + } + let expected_bpp = source_channels.checked_mul(self.precision.bytes).ok_or_else(|| invalid("bpp overflow"))?; + if bpp != expected_bpp { + return Err(invalid(format!("{bpp} bytes per pixel where {expected_bpp} were expected"))); + } + let indexed = self.base_type == 2; + let channels = if indexed { source_channels + 2 } else { source_channels }; + let count = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(channels)).ok_or_else(|| invalid("drawable overflow"))?; + let bytes = count.checked_mul(4).ok_or_else(|| invalid("drawable overflow"))?; + self.budget = self.budget.checked_sub(bytes).ok_or_else(|| invalid("the layers exceed the 1 GiB pixel limit"))?; + let lptr = c.ptr()?; + let mut l = self.c.at(lptr); + let (lw, lh) = (l.u32()?, l.u32()?); + if (lw, lh) != (w, h) { + return Err(invalid("the level does not match its hierarchy")); + } + let across = w.div_ceil(TILE) as usize; + let down = h.div_ceil(TILE) as usize; + let mut tiles = Vec::with_capacity(across * down); + for _ in 0..across * down { + let p = l.ptr()?; + if p == 0 { + return Err(invalid("the tile list ends before every tile")); + } + tiles.push(p); + } + let mut data = vec![0.0f32; count]; + let stride = w as usize * channels; + for (i, &at) in tiles.iter().enumerate() { + if i % 64 == 0 { + ctl.check().map_err(|_| IoError::Cancelled)?; + } + let (tx, ty) = ((i % across) as u32, (i / across) as u32); + let (tw, th) = ((w - tx * TILE).min(TILE), (h - ty * TILE).min(TILE)); + let end = tiles.get(i + 1).copied().filter(|&n| n > at).unwrap_or(self.c.b.len()); + let raw = decode_tile(&self.c, at, end, self.compression, bpp, (tw * th) as usize)?; + for py in 0..th as usize { + for px in 0..tw as usize { + let src = (py * tw as usize + px) * bpp; + let dst = (ty as usize * TILE as usize + py) * stride + (tx as usize * TILE as usize + px) * channels; + let pixel = &raw[src..src + bpp]; + if indexed { + let idx = usize::from(pixel[0]); + let [r, g, b] = self.colormap.get(idx).copied().unwrap_or([pixel[0]; 3]); + data[dst] = f32::from(r) / 255.0; + data[dst + 1] = f32::from(g) / 255.0; + data[dst + 2] = f32::from(b) / 255.0; + if source_channels == 2 { + data[dst + 3] = f32::from(pixel[1]) / 255.0; + } + } else { + for ch in 0..channels { + data[dst + ch] = self.precision.sample(&pixel[ch * self.precision.bytes..(ch + 1) * self.precision.bytes]); + } + } + } + } + } + Ok(Pixels { width: w, height: h, channels, data }) + } + + /// A channel structure (a layer mask, the selection or an extra channel) as one-channel + /// pixels, with its properties. + fn channel(&mut self, ptr: usize, ctl: &Interrupt<'_>) -> Result<(String, Vec>, Pixels)> { + let mut c = self.c.at(ptr); + let (_w, _h) = (c.u32()?, c.u32()?); + let name = c.string()?; + let props = props(&mut c)?; + let hptr = c.ptr()?; + let saved = (self.base_type, self.precision); + // Channels are always one plain sample per pixel, even in indexed images. + self.base_type = 0; + let px = self.hierarchy(hptr, 1, ctl); + (self.base_type, self.precision) = saved; + Ok((name, props, px?)) + } +} + +/// A layer as read, before the tree is built. +struct Node { + layer: Layer, + path: Vec, + group: bool, + children: Vec, +} + +/// GIMP's layer mode as Photoshop's, with a note when the two differ. +fn blend_mode(mode: u32) -> (BlendMode, Option<&'static str>) { + use BlendMode as B; + match mode { + 0 | 28 => (B::Normal, None), + 1 => (B::Dissolve, None), + 2 | 29 => (B::Normal, Some("Behind")), + 3 | 30 => (B::Multiply, None), + 4 | 31 => (B::Screen, None), + 5 | 19 => (B::SoftLight, Some("GIMP's legacy Soft light")), + 45 => (B::SoftLight, Some("GIMP's Soft light")), + 23 => (B::Overlay, None), + 6 | 32 => (B::Difference, None), + 7 | 33 => (B::LinearDodge, None), + 8 | 34 => (B::Subtract, None), + 9 | 35 => (B::Darken, None), + 10 | 36 => (B::Lighten, None), + 11 | 37 => (B::Hue, Some("HSV Hue")), + 12 | 38 => (B::Saturation, Some("HSV Saturation")), + 13 | 39 => (B::Color, Some("HSL Color")), + 14 | 40 => (B::Luminosity, Some("HSV Value")), + 15 | 41 => (B::Divide, None), + 16 | 42 => (B::ColorDodge, None), + 17 | 43 => (B::ColorBurn, None), + 18 | 44 => (B::HardLight, None), + 20 | 46 => (B::Normal, Some("Grain extract")), + 21 | 47 => (B::Normal, Some("Grain merge")), + 22 | 57 => (B::Normal, Some("Color erase")), + 24 => (B::Hue, Some("LCH Hue")), + 25 => (B::Saturation, Some("LCH Chroma")), + 26 => (B::Color, Some("LCH Color")), + 27 => (B::Luminosity, Some("LCH Lightness")), + 48 => (B::VividLight, None), + 49 => (B::PinLight, None), + 50 => (B::LinearLight, None), + 51 => (B::HardMix, None), + 52 => (B::Exclusion, None), + 53 => (B::LinearBurn, None), + 54 => (B::DarkerColor, Some("Luma darken only")), + 55 => (B::LighterColor, Some("Luma lighten only")), + 56 => (B::Luminosity, Some("Luminance")), + 58 => (B::Normal, Some("Erase")), + 59 => (B::Normal, Some("Merge")), + 60 => (B::Normal, Some("Split")), + 61 => (B::PassThrough, None), + _ => (B::Normal, Some("an unknown mode")), + } +} + +fn label(tag: u32) -> LabelColor { + match tag { + 1 => LabelColor::Blue, + 2 => LabelColor::Green, + 3 => LabelColor::Yellow, + 4 => LabelColor::Orange, + 6 => LabelColor::Red, + 7 => LabelColor::Violet, + 8 => LabelColor::Gray, + _ => LabelColor::None, + } +} + +/// Writes `px` (its channels matching `surface`'s) at `x, y` of the document. +fn write_pixels(surface: &mut Surface, px: &Pixels, x: i32, y: i32) -> Result<()> { + let (w, h) = (px.width as i32, px.height as i32); + let rect = Rect::new(x, y, x.checked_add(w).ok_or_else(|| invalid("offset overflow"))?, y.checked_add(h).ok_or_else(|| invalid("offset overflow"))?); + if surface.channels() != px.channels || px.data.len() != (px.width as usize) * (px.height as usize) * px.channels { + return Err(invalid("pixel data does not match the drawable")); + } + surface.write_region(rect, &px.data); + surface.prune(); + Ok(()) +} + +/// Imports an XCF file (already inflated when it was gzipped). +pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { + if !is_xcf(bytes) { + return Err(invalid("not an XCF file")); + } + let mut c = Cursor { b: bytes, pos: MAGIC.len(), wide: false }; + let tag = c.take(5)?; + let version = match tag { + b"file\0" => 0, + [b'v', a, b, d, 0] if a.is_ascii_digit() && b.is_ascii_digit() && d.is_ascii_digit() => { + u32::from(a - b'0') * 100 + u32::from(b - b'0') * 10 + u32::from(d - b'0') + } + _ => return Err(invalid("unknown XCF version tag")), + }; + c.wide = version >= 11; + let mut warnings = Warnings::default(); + if version > KNOWN_VERSION { + warnings.note(&format!("XCF version {version} is newer than this reader (version {KNOWN_VERSION}); unknown parts were skipped")); + } + let (w, h, base_type) = (c.u32()?, c.u32()?, c.u32()?); + if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE { + return Err(invalid(format!("canvas {w}x{h} must be 1 to {MAX_SIDE} pixels a side"))); + } + if (w as usize).saturating_mul(h as usize).saturating_mul(16) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); + } + let precision = if version >= 4 { Precision::from_header(version, c.u32()?)? } else { Precision::GAMMA8 }; + let mode = match base_type { + 0 => ColorMode::Rgb, + 1 => ColorMode::Grayscale, + 2 => ColorMode::Rgb, + t => return Err(invalid(format!("unknown image type {t}"))), + }; + let depth = precision.depth(); + if base_type == 2 { + warnings.note("the indexed image opens as RGB"); + } + if !precision.float && precision.bytes == 4 { + warnings.note("32-bit integer samples open as 16-bit"); + } + + // Image properties. + let image_props = props(&mut c)?; + let mut compression = Compression::None; + let mut colormap = Vec::new(); + let mut resolution = None; + let mut guides: Vec<(i32, u8)> = Vec::new(); + let mut icc = None; + let mut comment = None; + let mut path_count = 0usize; + for p in &image_props { + match p.kind { + 1 => { + let n = be_u32(p.data).unwrap_or(0) as usize; + colormap = p.data.get(4..).unwrap_or(&[]).as_chunks::<3>().0.iter().take(n.min(256)).copied().collect(); + } + 17 => { + compression = match p.data.first() { + Some(0) => Compression::None, + Some(1) => Compression::Rle, + Some(2) => Compression::Zlib, + Some(k) => return Err(invalid(format!("unknown compression {k}"))), + None => return Err(invalid("empty compression property")), + } + } + 18 => { + for g in p.data.as_chunks::<5>().0 { + guides.push((i32::from_be_bytes([g[0], g[1], g[2], g[3]]), g[4])); + } + } + 19 => { + if let (Some(x), Some(y)) = (be_f32(p.data), p.data.get(4..).and_then(be_f32)) { + resolution = Some((x, y)); + } + } + 21 => { + for (name, data) in parasites(p.data) { + match name.as_str() { + "icc-profile" if !data.is_empty() => icc = Some(data), + "gimp-comment" => comment = Some(String::from_utf8_lossy(&data).trim_end_matches('\0').to_string()), + _ => {} + } + } + } + 23 => path_count += be_u32(p.data.get(4..).unwrap_or(&[])).unwrap_or(0) as usize, + 25 => path_count += be_u32(p.data.get(8..).unwrap_or(&[])).unwrap_or(0) as usize, + _ => {} + } + } + + let mut layer_ptrs = Vec::new(); + loop { + let p = c.ptr()?; + if p == 0 { + break; + } + layer_ptrs.push(p); + if layer_ptrs.len() > MAX_LAYERS { + return Err(invalid(format!("more than {MAX_LAYERS} layers"))); + } + } + let mut channel_ptrs = Vec::new(); + loop { + let p = c.ptr()?; + if p == 0 { + break; + } + channel_ptrs.push(p); + if channel_ptrs.len() > MAX_CHANNELS { + return Err(invalid(format!("more than {MAX_CHANNELS} channels"))); + } + } + if version >= 18 { + let mut n = 0; + while c.ptr().ok().is_some_and(|p| p != 0) { + n += 1; + if n > MAX_CHANNELS { + break; + } + } + path_count += n; + } + + let mut image = Image { + c: c.at(0), + version, + precision, + compression, + base_type, + colormap, + budget: MAX_PIXEL_BYTES, + meta_budget: MAX_METADATA_BYTES, + warnings, + clipped_to_backdrop: 0, + }; + let fmt = PixelFormat::new(mode, depth, true); + let mask_fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + let total = layer_ptrs.len().max(1) as f32; + + // Layers, topmost first in the file. + let mut nodes: Vec = Vec::new(); + let mut linear_compositing = false; + for (i, &ptr) in layer_ptrs.iter().enumerate() { + ctl.check().map_err(|_| IoError::Cancelled)?; + ctl.progress(0.05 + 0.85 * i as f32 / total); + if let Some((node, linear)) = image.layer(ptr, fmt, mask_fmt, ctl)? { + linear_compositing |= linear; + nodes.push(node); + } + } + let mut roots: Vec = Vec::new(); + for (i, mut node) in nodes.into_iter().enumerate() { + // Default path: the item's position at the top level, as a file without groups has it. + if node.path.is_empty() { + node.path = vec![i as u32]; + } + if node.path.len() > MAX_DEPTH { + image.warnings.layer(&node.layer.name, "is nested deeper than the reader allows and was moved to the top level"); + node.path.truncate(1); + } + place(&mut roots, node, &mut image.warnings); + } + let layers = finish(roots); + + // Channels: the selection and the extras. + let mut channels = Vec::new(); + let mut selection = None; + for &ptr in &channel_ptrs { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (name, cprops, px) = image.channel(ptr, ctl)?; + let mut surface = Surface::new(mask_fmt); + write_pixels(&mut surface, &px, 0, 0)?; + if cprops.iter().any(|p| p.kind == 4) { + selection = Some(surface); + } else { + channels.push(AlphaChannel::new(name, surface)); + } + } + + let mut document = Document::new(name, Size::new(w, h), mode, depth); + document.layers = layers; + document.channels = channels; + document.selection = selection; + if let Some((x, y)) = resolution.filter(|(x, _)| *x > 0.0 && *x < 1.0e6) { + document.resolution_dpi = x; + if let Some(w) = crate::unequal_resolution_warning(f64::from(x), f64::from(y)) { + image.warnings.note(&w); + } + } + for (coord, orientation) in guides { + if coord < 0 && version < 15 { + continue; + } + match orientation { + 1 => document.guides.horizontal.push(f64::from(coord)), + 2 => document.guides.vertical.push(f64::from(coord)), + _ => {} + } + } + document.icc_profile = match (precision.linear, icc, mode) { + (true, icc, ColorMode::Grayscale) => { + if icc.is_some() { + image.warnings.note("the linear-light image's own profile was replaced by linear gray"); + } + Some(photocraft_cms::builtin::linear_gray().to_bytes()) + } + (true, icc, _) => { + if icc.is_some() { + image.warnings.note("the linear-light image's own profile was replaced by linear sRGB"); + } + Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()) + } + (false, Some(icc), _) => Some(Arc::new(icc)), + (false, None, _) => None, + }; + if let Some(comment) = comment.filter(|c| !c.is_empty()) { + document.metadata.text.push(("Comment".into(), comment)); + } + if path_count > 0 { + image.warnings.note(&format!("{path_count} path(s) were not imported")); + } + // GIMP clips a layer in any mode but Normal to the layers beneath it, so it shows nothing where + // they are transparent; PhotoCraft (like Photoshop) shows it there. Only tell when there is + // transparency to show through: a bottom layer that is not opaque. + if image.clipped_to_backdrop > 0 && document.layers.first().is_some_and(|l| !l.locks.transparency || l.is_group()) { + image.warnings.note(&format!( + "{} layer(s) use a mode GIMP clips to the layers beneath; where those are transparent GIMP shows nothing and PhotoCraft shows the layer", + image.clipped_to_backdrop + )); + } + // GIMP blends and composites in linear light by default (since 2.10); PhotoCraft, like + // Photoshop, in the document's own encoding, so semi-transparent edges and most blend modes + // render a little differently. Layers set to a perceptual space in GIMP match. + if !precision.linear && linear_compositing { + image.warnings.note("GIMP composites this image in linear light; PhotoCraft composites in gamma-encoded RGB, so blends and soft edges differ slightly"); + } + if document.layers.is_empty() { + image.warnings.note("the XCF file has no layers"); + } + ctl.progress(1.0); + Ok(ImportResult { document, warnings: image.warnings.finish(), source_read_only: true, preview_only: false }) +} + +/// Puts `node` where its item path says, under the groups already placed; a path that leads +/// nowhere (at any depth) puts it at the top level, so later siblings keep their indices. +fn place(roots: &mut Vec, node: Node, warnings: &mut Warnings) { + let depth = node.path.len().saturating_sub(1); + let mut probe: &Vec = roots; + let reachable = node.path.iter().take(depth).all(|&index| match probe.get(index as usize) { + Some(n) if n.group => { + probe = &n.children; + true + } + _ => false, + }); + if !reachable { + warnings.layer(&node.layer.name, "has an item path that leads to no group; opened at the top level"); + let mut node = node; + node.path = vec![]; + roots.push(node); + return; + } + let mut level = roots; + for &index in node.path.iter().take(depth) { + level = &mut level[index as usize].children; + } + level.push(node); +} + +/// Nodes top-first into layers bottom-first, groups recursively. +fn finish(nodes: Vec) -> Vec { + let mut out = Vec::with_capacity(nodes.len()); + for mut node in nodes.into_iter().rev() { + if node.group { + let children = finish(std::mem::take(&mut node.children)); + if let Some(c) = node.layer.children_mut() { + *c = children; + } + } + out.push(node.layer); + } + out +} + +impl<'a> Image<'a> { + /// Reads a layer structure, and whether GIMP composites it in linear light. `None` for a + /// floating selection, which is reported instead. + fn layer(&mut self, ptr: usize, fmt: PixelFormat, mask_fmt: PixelFormat, ctl: &Interrupt<'_>) -> Result> { + let mut c = self.c.at(ptr); + let (lw, lh, kind) = (c.u32()?, c.u32()?, c.u32()?); + let name = c.string()?; + // Charge the owned name against the document-wide metadata budget: repeated layer + // offsets referencing one large name must not retain unbounded copies. + self.meta_budget = self.meta_budget.checked_sub(name.len()).ok_or_else(|| invalid("layer metadata exceeds the budget"))?; + let lprops = props(&mut c)?; + let hptr = c.ptr()?; + let mptr = c.ptr()?; + let mut effects = 0usize; + if self.version >= 20 { + while c.ptr()? != 0 { + effects += 1; + if effects > 1024 { + return Err(invalid("too many layer effects")); + } + } + } + let (type_rgb, alpha) = match kind { + 0 => (true, false), + 1 => (true, true), + 2 | 4 => (false, false), + 3 | 5 => (false, true), + k => return Err(invalid(format!("unknown layer type {k} on '{name}'"))), + }; + if type_rgb != (self.base_type != 1) && self.base_type != 2 { + return Err(invalid(format!("layer '{name}' does not match the image's colour model"))); + } + + let mut layer_mode = 0u32; + let mut opacity = 1.0f32; + // PROP_FLOAT_OPACITY wins over PROP_OPACITY whatever their order in the file. + let mut float_opacity = None; + let mut visible = true; + let (mut x, mut y) = (0i32, 0i32); + let mut apply_mask = true; + let mut group = false; + let mut expanded = true; + let mut path = Vec::new(); + let mut text = false; + let mut vector = false; + let mut link = false; + let mut floating = false; + let mut clipped = false; + let mut composite_mode = 0i32; + let mut composite_space = 0i32; + let mut blend_space = 0i32; + let mut locks = photocraft_doc::Locks::default(); + let mut tag = LabelColor::None; + for p in &lprops { + let v = be_u32(p.data).unwrap_or(0); + match p.kind { + 5 => floating = true, + 6 => opacity = (v.min(255) as f32) / 255.0, + 33 => float_opacity = be_f32(p.data).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)).or(float_opacity), + 7 => layer_mode = v, + 8 => visible = v != 0, + 10 => locks.transparency = v != 0, + 11 => apply_mask = v != 0, + 15 => { + x = be_i32(p.data).unwrap_or(0); + y = p.data.get(4..).and_then(be_i32).unwrap_or(0); + } + 26 => text = true, + 28 => locks.pixels = v != 0, + 29 => group = true, + 30 => { + path = p.data.as_chunks::<4>().0.iter().map(|s| u32::from_be_bytes(*s)).collect(); + self.meta_budget = + self.meta_budget.checked_sub(path.len().saturating_mul(4)).ok_or_else(|| invalid("layer metadata exceeds the budget"))?; + } + 31 => expanded = v & 1 != 0, + 32 => locks.position = v != 0, + 34 => tag = label(v), + 35 => composite_mode = be_i32(p.data).unwrap_or(0), + 36 => composite_space = be_i32(p.data).unwrap_or(0), + 37 => blend_space = be_i32(p.data).unwrap_or(0), + 47 => vector = true, + 48 => link = true, + 21 => { + for (pname, _) in parasites(p.data) { + match pname.as_str() { + "gimphoto-clipped" => clipped = true, + "gimp-text-layer" => text = true, + _ => {} + } + } + } + _ => {} + } + } + if floating { + self.warnings.layer(&name, "is a floating selection and was not imported (anchor it in GIMP first)"); + return Ok(None); + } + + let (blend, note) = blend_mode(layer_mode); + if let Some(note) = note { + self.warnings.layer( + &name, + &format!( + "uses GIMP's {note} mode, which opens as {}", + if blend == BlendMode::Normal { "Normal".to_string() } else { format!("Photoshop's {blend:?}") } + ), + ); + } + match composite_mode.unsigned_abs() { + 0 | 1 => {} + 2 => self.clipped_to_backdrop += 1, + 3 => self.warnings.layer(&name, "composites with Clip to layer, which is not reproduced"), + 4 => self.warnings.layer(&name, "composites with Intersection, which is not reproduced"), + _ => {} + } + if text { + self.warnings.layer(&name, "is a text layer and opens as pixels"); + } + if vector { + self.warnings.layer(&name, "is a vector layer and opens as pixels"); + } + if link { + self.warnings.layer(&name, "is a link layer and opens as pixels"); + } + if effects > 0 { + self.warnings.layer(&name, &format!("has {effects} layer effect(s) (non-destructive filters) that are not applied")); + } + + let mut layer = if group { + let mut g = Layer::group(name.clone(), Vec::new()); + if let LayerContent::Group(gr) = &mut g.content { + gr.expanded = expanded; + } + g.blend = if blend == BlendMode::PassThrough { BlendMode::PassThrough } else { blend }; + g + } else { + let mut l = Layer::raster(name.clone(), fmt); + l.blend = if blend == BlendMode::PassThrough { BlendMode::Normal } else { blend }; + l + }; + layer.opacity = float_opacity.unwrap_or(opacity); + layer.visible = visible; + layer.locks = locks; + layer.label = tag; + layer.clipped = clipped; + + if !group && hptr != 0 { + let source_channels = match (self.base_type, alpha) { + (2, false) => 1, + (2, true) => 2, + (1, false) => 1, + (1, true) => 2, + (_, false) => 3, + (_, true) => 4, + }; + let mut px = self.hierarchy(hptr, source_channels, ctl)?; + if (px.width, px.height) != (lw, lh) { + return Err(invalid(format!("layer '{name}' pixels are {}x{} where {lw}x{lh} were declared", px.width, px.height))); + } + if !alpha { + // An opaque layer: add the alpha channel the document's pixel format carries. + let n = px.channels; + let mut data = Vec::with_capacity(px.data.len() / n * (n + 1)); + for p in px.data.chunks_exact(n) { + data.extend_from_slice(p); + data.push(1.0); + } + px = Pixels { width: px.width, height: px.height, channels: n + 1, data }; + } + let mut surface = Surface::new(fmt); + write_pixels(&mut surface, &px, x, y)?; + layer.content = LayerContent::Raster(surface); + if !alpha { + layer.locks.transparency = true; + } + } + if mptr != 0 { + let (_mname, _mprops, px) = self.channel(mptr, ctl)?; + let mut surface = Surface::new(mask_fmt); + write_pixels(&mut surface, &px, x, y)?; + layer.mask = Some(LayerMask { surface, enabled: apply_mask, linked: true, density: 1.0, feather: 0.0 }); + } + let linear = composite_space.unsigned_abs() == 1 || blend_space.unsigned_abs() == 1; + Ok(Some((Node { layer, path, group, children: Vec::new() }, linear))) + } +} diff --git a/crates/io/src/xcf/tests.rs b/crates/io/src/xcf/tests.rs new file mode 100644 index 0000000000..400887f92b --- /dev/null +++ b/crates/io/src/xcf/tests.rs @@ -0,0 +1,734 @@ +//! Synthetic XCF files (written here, from the specification) for the reader: structure, +//! every compression, precisions, groups, masks, channels, limits and malformed input. + +use super::*; +use photocraft_raster::Interrupt; + +/// A small XCF writer for tests: version 11+ layout (64-bit pointers) by default. +pub(super) struct W { + pub out: Vec, + pub wide: bool, +} + +impl W { + pub fn new(version: u32) -> Self { + let mut w = W { out: Vec::new(), wide: version >= 11 }; + w.out.extend_from_slice(b"gimp xcf "); + if version == 0 { + w.out.extend_from_slice(b"file\0"); + } else { + w.out.extend_from_slice(format!("v{version:03}\0").as_bytes()); + } + w + } + pub fn u32(&mut self, v: u32) { + self.out.extend_from_slice(&v.to_be_bytes()); + } + pub fn ptr(&mut self, v: usize) { + if self.wide { + self.out.extend_from_slice(&(v as u64).to_be_bytes()); + } else { + self.u32(v as u32); + } + } + pub fn string(&mut self, s: &str) { + if s.is_empty() { + self.u32(0); + } else { + self.u32(s.len() as u32 + 1); + self.out.extend_from_slice(s.as_bytes()); + self.out.push(0); + } + } + pub fn prop(&mut self, kind: u32, payload: &[u8]) { + self.u32(kind); + self.u32(payload.len() as u32); + self.out.extend_from_slice(payload); + } + pub fn prop_u32(&mut self, kind: u32, v: u32) { + self.prop(kind, &v.to_be_bytes()); + } + pub fn end(&mut self) { + self.u32(0); + self.u32(0); + } + /// Reserves a pointer slot, returning where to patch it. + pub fn slot(&mut self) -> usize { + let at = self.out.len(); + self.ptr(0); + at + } + pub fn patch(&mut self, slot: usize, target: usize) { + if self.wide { + self.out[slot..slot + 8].copy_from_slice(&(target as u64).to_be_bytes()); + } else { + self.out[slot..slot + 4].copy_from_slice(&(target as u32).to_be_bytes()); + } + } +} + +/// RLE-encodes one tile of `bpp`-byte pixels, plane by plane, with runs and literals. +pub(super) fn rle(pixels: &[u8], bpp: usize) -> Vec { + let n = pixels.len() / bpp; + let mut out = Vec::new(); + for b in 0..bpp { + let plane: Vec = (0..n).map(|i| pixels[i * bpp + b]).collect(); + let mut i = 0; + while i < n { + let v = plane[i]; + let mut run = 1; + while i + run < n && plane[i + run] == v && run < 65535 { + run += 1; + } + if run >= 2 { + if run <= 127 { + out.push((run - 1) as u8); + } else { + out.push(127); + out.push((run / 256) as u8); + out.push((run % 256) as u8); + } + out.push(v); + i += run; + } else { + let start = i; + while i < n && (i + 1 >= n || plane[i + 1] != plane[i]) && i - start < 127 { + i += 1; + } + let len = i - start; + out.push((256 - len) as u8); + out.extend_from_slice(&plane[start..i]); + } + } + } + out +} + +/// Writes a hierarchy for `w`×`h` pixels of `bpp` bytes (`data` row-major), returning its offset. +pub(super) fn hierarchy(w: &mut W, width: u32, height: u32, bpp: usize, data: &[u8], compression: Compression) -> usize { + let at = w.out.len(); + w.u32(width); + w.u32(height); + w.u32(bpp as u32); + let level_slot = w.slot(); + w.ptr(0); + let level = w.out.len(); + w.patch(level_slot, level); + w.u32(width); + w.u32(height); + let across = width.div_ceil(TILE) as usize; + let down = height.div_ceil(TILE) as usize; + let slots: Vec = (0..across * down).map(|_| w.slot()).collect(); + w.ptr(0); + for (i, slot) in slots.into_iter().enumerate() { + let (tx, ty) = ((i % across) as u32, (i / across) as u32); + let (tw, th) = ((width - tx * TILE).min(TILE), (height - ty * TILE).min(TILE)); + let mut tile = Vec::new(); + for py in 0..th { + for px in 0..tw { + let src = ((ty * TILE + py) as usize * width as usize + (tx * TILE + px) as usize) * bpp; + tile.extend_from_slice(&data[src..src + bpp]); + } + } + let encoded = match compression { + Compression::None => tile, + Compression::Rle => rle(&tile, bpp), + Compression::Zlib => { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&tile).unwrap(); + e.finish().unwrap() + } + }; + let here = w.out.len(); + w.patch(slot, here); + w.out.extend_from_slice(&encoded); + } + at +} + +/// A layer to write. +pub(super) struct TestLayer { + pub name: &'static str, + pub width: u32, + pub height: u32, + pub kind: u32, + pub x: i32, + pub y: i32, + pub opacity: f32, + pub visible: bool, + pub mode: u32, + pub props: Vec<(u32, Vec)>, + pub data: Vec, + pub mask: Option>, + pub apply_mask: bool, +} + +impl TestLayer { + pub fn rgba(name: &'static str, width: u32, height: u32, data: Vec) -> Self { + TestLayer { name, width, height, kind: 1, x: 0, y: 0, opacity: 1.0, visible: true, mode: 28, props: Vec::new(), data, mask: None, apply_mask: true } + } +} + +pub(super) struct TestImage { + pub version: u32, + pub width: u32, + pub height: u32, + pub base_type: u32, + pub precision: u32, + pub compression: Compression, + pub image_props: Vec<(u32, Vec)>, + pub layers: Vec, + pub channels: Vec<(&'static str, Vec, bool)>, +} + +impl TestImage { + pub fn rgb8(width: u32, height: u32) -> Self { + TestImage { + version: 11, + width, + height, + base_type: 0, + precision: 150, + compression: Compression::Rle, + image_props: Vec::new(), + layers: Vec::new(), + channels: Vec::new(), + } + } + + pub fn write(&self) -> Vec { + let mut w = W::new(self.version); + w.u32(self.width); + w.u32(self.height); + w.u32(self.base_type); + if self.version >= 4 { + w.u32(self.precision); + } + let comp = match self.compression { + Compression::None => 0u8, + Compression::Rle => 1, + Compression::Zlib => 2, + }; + w.prop(17, &[comp]); + for (k, v) in &self.image_props { + w.prop(*k, v); + } + w.end(); + let layer_slots: Vec = self.layers.iter().map(|_| w.slot()).collect(); + w.ptr(0); + let channel_slots: Vec = self.channels.iter().map(|_| w.slot()).collect(); + w.ptr(0); + if self.version >= 18 { + w.ptr(0); + } + let bytes = match self.precision { + 100 | 150 => 1, + 200 | 250 | 500 | 550 => 2, + 300 | 350 | 600 | 650 => 4, + _ => 8, + }; + for (layer, slot) in self.layers.iter().zip(layer_slots) { + let at = w.out.len(); + w.patch(slot, at); + w.u32(layer.width); + w.u32(layer.height); + w.u32(layer.kind); + w.string(layer.name); + w.prop_u32(6, (layer.opacity * 255.0).round() as u32); + w.prop(33, &layer.opacity.to_be_bytes()); + w.prop_u32(8, u32::from(layer.visible)); + w.prop_u32(7, layer.mode); + let mut off = Vec::new(); + off.extend_from_slice(&layer.x.to_be_bytes()); + off.extend_from_slice(&layer.y.to_be_bytes()); + w.prop(15, &off); + if layer.mask.is_some() { + w.prop_u32(11, u32::from(layer.apply_mask)); + } + for (k, v) in &layer.props { + w.prop(*k, v); + } + w.end(); + let hslot = w.slot(); + let mslot = w.slot(); + if self.version >= 20 { + w.ptr(0); + } + let channels = match layer.kind { + 0 => 3, + 1 => 4, + 2 | 4 => 1, + _ => 2, + }; + if !layer.data.is_empty() { + let h = hierarchy(&mut w, layer.width, layer.height, channels * bytes, &layer.data, self.compression); + w.patch(hslot, h); + } + if let Some(mask) = &layer.mask { + let at = w.out.len(); + w.patch(mslot, at); + w.u32(layer.width); + w.u32(layer.height); + w.string("mask"); + w.end(); + let hs = w.slot(); + let h = hierarchy(&mut w, layer.width, layer.height, bytes, mask, self.compression); + w.patch(hs, h); + } + } + for ((name, data, selection), slot) in self.channels.iter().zip(channel_slots) { + let at = w.out.len(); + w.patch(slot, at); + w.u32(self.width); + w.u32(self.height); + w.string(name); + if *selection { + w.prop(4, &[]); + } + w.end(); + let hs = w.slot(); + let h = hierarchy(&mut w, self.width, self.height, bytes, data, self.compression); + w.patch(hs, h); + } + w.out + } +} + +fn open(bytes: &[u8]) -> Result { + import("test.xcf", bytes, &Interrupt::NONE) +} + +fn rgba(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 4]) -> Vec { + let f = &f; + (0..h).flat_map(|y| (0..w).flat_map(move |x| f(x, y))).collect() +} + +fn pixel(layer: &Layer, x: i32, y: i32) -> [f32; 4] { + let LayerContent::Raster(s) = &layer.content else { panic!("not raster") }; + let px = s.read_region(Rect::new(x, y, x + 1, y + 1)); + [px[0], px[1], px[2], px[3]] +} + +#[test] +fn a_layer_decodes_with_every_compression() { + for compression in [Compression::None, Compression::Rle, Compression::Zlib] { + let mut img = TestImage::rgb8(70, 66); + img.compression = compression; + img.layers.push(TestLayer::rgba("A", 70, 66, rgba(70, 66, |x, y| [x as u8 * 3, y as u8 * 2, (x + y) as u8, 255 - x as u8]))); + let r = open(&img.write()).unwrap(); + let d = &r.document; + assert_eq!((d.size.width, d.size.height), (70, 66)); + assert_eq!(d.layers.len(), 1, "{compression:?}"); + for (x, y) in [(0, 0), (63, 0), (64, 0), (69, 65), (10, 64)] { + let p = pixel(&d.layers[0], x, y); + let want = [x as f32 * 3.0, y as f32 * 2.0, (x + y) as f32, 255.0 - x as f32].map(|v| v / 255.0); + assert!((0..4).all(|c| (p[c] - want[c]).abs() < 1e-6), "{compression:?} at {x},{y}: {p:?} vs {want:?}"); + } + assert!(r.source_read_only); + } +} + +#[test] +fn every_truncation_errors_never_panics() { + let mut img = TestImage::rgb8(20, 20); + img.layers.push(TestLayer::rgba("A", 20, 20, rgba(20, 20, |x, y| [x as u8, y as u8, 0, 255]))); + let bytes = img.write(); + for cut in 0..bytes.len() { + let _ = open(&bytes[..cut]); + } + assert!(open(&bytes).is_ok()); +} + +fn layer_at(layers: &[Layer], name: &str) -> Layer { + layers.iter().find(|l| l.name == name).cloned().unwrap_or_else(|| panic!("no layer {name}")) +} + +#[test] +fn properties_offsets_opacity_mode_visibility_locks_and_tags() { + let mut img = TestImage::rgb8(30, 20); + let mut l = TestLayer::rgba("Top", 10, 8, rgba(10, 8, |x, y| [x as u8 * 20, y as u8 * 30, 0, 200])); + l.x = -3; + l.y = 15; + l.opacity = 0.4; + l.visible = false; + l.mode = 30; // Multiply + l.props = vec![(10, 1u32.to_be_bytes().to_vec()), (28, 1u32.to_be_bytes().to_vec()), (32, 1u32.to_be_bytes().to_vec()), (34, 2u32.to_be_bytes().to_vec())]; + img.layers.push(l); + img.layers.push(TestLayer::rgba("Bottom", 30, 20, rgba(30, 20, |_, _| [9, 9, 9, 255]))); + let r = open(&img.write()).unwrap(); + let d = &r.document; + assert_eq!(d.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Bottom", "Top"], "topmost in the file is last in the stack"); + let top = &d.layers[1]; + assert_eq!((top.blend, top.opacity, top.visible), (BlendMode::Multiply, 0.4, false)); + assert!(top.locks.transparency && top.locks.pixels && top.locks.position); + assert_eq!(top.label, LabelColor::Green); + // Offset (-3, 15): the layer's pixel (5, 2) sits at document (2, 17). + let p = pixel(top, 2, 17); + assert!((p[0] - 100.0 / 255.0).abs() < 1e-6 && (p[1] - 60.0 / 255.0).abs() < 1e-6 && (p[3] - 200.0 / 255.0).abs() < 1e-6, "{p:?}"); + assert_eq!(pixel(top, 2, 14)[3], 0.0, "above the layer"); +} + +#[test] +fn groups_follow_item_paths_and_pass_through() { + // File order: Top, Group (0), child A [0,0], Nested group [0,1], nested child [0,1,0], Bottom. + let mut img = TestImage::rgb8(16, 16); + let px = |v: u8| rgba(4, 4, move |_, _| [v, v, v, 255]); + img.layers.push(TestLayer::rgba("Top", 4, 4, px(1))); + let mut group = TestLayer::rgba("Group", 1, 1, Vec::new()); + group.props = vec![(29, vec![]), (31, 0u32.to_be_bytes().to_vec())]; + group.mode = 28; + img.layers.push(group); + let mut a = TestLayer::rgba("A", 4, 4, px(2)); + a.props = vec![(30, [1u32, 0].iter().flat_map(|v| v.to_be_bytes()).collect())]; + img.layers.push(a); + let mut nested = TestLayer::rgba("Nested", 1, 1, Vec::new()); + nested.props = vec![(29, vec![]), (30, [1u32, 1].iter().flat_map(|v| v.to_be_bytes()).collect())]; + nested.mode = 61; // Pass through + img.layers.push(nested); + let mut b = TestLayer::rgba("B", 4, 4, px(3)); + b.props = vec![(30, [1u32, 1, 0].iter().flat_map(|v| v.to_be_bytes()).collect())]; + img.layers.push(b); + img.layers.push(TestLayer::rgba("Bottom", 16, 16, rgba(16, 16, |_, _| [0, 0, 0, 255]))); + let d = open(&img.write()).unwrap().document; + assert_eq!(d.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Bottom", "Group", "Top"]); + let g = &d.layers[1]; + assert!(g.is_group() && g.blend == BlendMode::Normal); + if let LayerContent::Group(gr) = &g.content { + assert!(!gr.expanded); + } + let children = g.children().unwrap(); + assert_eq!(children.iter().map(|l| l.name.as_str()).collect::>(), ["Nested", "A"], "bottom first inside the group too"); + assert_eq!(children[0].blend, BlendMode::PassThrough); + assert_eq!(children[0].children().unwrap()[0].name, "B"); + assert_eq!(pixel(&layer_at(children, "A"), 0, 0)[0], 2.0 / 255.0); +} + +#[test] +fn a_path_into_nothing_lands_at_the_top_level_with_a_note() { + let mut img = TestImage::rgb8(8, 8); + let mut stray = TestLayer::rgba("Stray", 2, 2, rgba(2, 2, |_, _| [5, 5, 5, 255])); + stray.props = vec![(30, [7u32, 0].iter().flat_map(|v| v.to_be_bytes()).collect())]; + img.layers.push(stray); + img.layers.push(TestLayer::rgba("Bottom", 8, 8, rgba(8, 8, |_, _| [0, 0, 0, 255]))); + let r = open(&img.write()).unwrap(); + assert_eq!(r.document.layers.len(), 2); + assert!(r.warnings.iter().any(|w| w.contains("item path")), "{:?}", r.warnings); +} + +#[test] +fn masks_are_read_at_the_layer_offset_applied_or_not() { + for apply in [true, false] { + let mut img = TestImage::rgb8(20, 20); + let mut l = TestLayer::rgba("Masked", 6, 6, rgba(6, 6, |_, _| [255, 0, 0, 255])); + l.x = 4; + l.y = 5; + l.mask = Some((0..36).map(|i| (i * 7) as u8).collect()); + l.apply_mask = apply; + img.layers.push(l); + let d = open(&img.write()).unwrap().document; + let mask = d.layers[0].mask.as_ref().expect("mask"); + assert_eq!(mask.enabled, apply); + assert_eq!(mask.surface.read_region(Rect::new(4 + 3, 5 + 2, 4 + 4, 5 + 3))[0], (2 * 6 + 3) as f32 * 7.0 / 255.0); + } +} + +#[test] +fn sixteen_bit_and_float_samples_are_big_endian() { + // 16-bit gamma integer (250): one 2x1 gray+alpha layer. + let mut img = TestImage::rgb8(2, 1); + img.base_type = 1; + img.precision = 250; + let mut data = Vec::new(); + for v in [0x1234u16, 0xffff, 0x8000, 0x0001] { + data.extend_from_slice(&v.to_be_bytes()); + } + let mut l = TestLayer::rgba("G", 2, 1, data); + l.kind = 3; // gray with alpha + img.layers.push(l); + let d = open(&img.write()).unwrap().document; + assert_eq!((d.mode, d.depth), (ColorMode::Grayscale, SampleType::U16)); + let p = pixel_n(&d.layers[0], 0, 0, 2); + assert!((p[0] - 0x1234 as f32 / 65535.0).abs() < 1e-6 && (p[1] - 1.0).abs() < 1e-6, "{p:?}"); + let p = pixel_n(&d.layers[0], 1, 0, 2); + assert!((p[0] - 0x8000 as f32 / 65535.0).abs() < 1e-6 && (p[1] - 1.0 / 65535.0).abs() < 1e-6, "{p:?}"); + + // 32-bit float linear (600): RGB without alpha, values above 1 kept, linear profile. + let mut img = TestImage::rgb8(1, 1); + img.precision = 600; + let mut data = Vec::new(); + for v in [0.25f32, 1.5, -0.5] { + data.extend_from_slice(&v.to_be_bytes()); + } + let mut l = TestLayer::rgba("F", 1, 1, data); + l.kind = 0; + img.layers.push(l); + let r = open(&img.write()).unwrap(); + let d = &r.document; + assert_eq!(d.depth, SampleType::F32); + assert_eq!(pixel(&d.layers[0], 0, 0), [0.25, 1.5, -0.5, 1.0]); + assert!(d.icc_profile.is_some(), "linear light gets a linear profile"); + assert!(d.layers[0].locks.transparency, "no alpha channel in the file: the layer is opaque"); + + // 16-bit float (500) and 64-bit float (700) decode too. + for (precision, bytes) in [(500u32, f16::from_f32(0.75).to_bits().to_be_bytes().to_vec()), (700, 0.75f64.to_be_bytes().to_vec())] { + let mut img = TestImage::rgb8(1, 1); + img.base_type = 1; + img.precision = precision; + let mut l = TestLayer::rgba("F", 1, 1, bytes); + l.kind = 2; + img.layers.push(l); + let d = open(&img.write()).unwrap().document; + assert_eq!(pixel_n(&d.layers[0], 0, 0, 2)[0], 0.75, "precision {precision}"); + } +} + +fn pixel_n(layer: &Layer, x: i32, y: i32, n: usize) -> Vec { + let LayerContent::Raster(s) = &layer.content else { panic!("not raster") }; + let px = s.read_region(Rect::new(x, y, x + 1, y + 1)); + assert_eq!(px.len(), n); + px +} + +#[test] +fn indexed_images_use_the_colormap() { + let mut img = TestImage::rgb8(2, 1); + img.base_type = 2; + let mut cmap = 3u32.to_be_bytes().to_vec(); + cmap.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255]); + img.image_props.push((1, cmap)); + let mut l = TestLayer::rgba("I", 2, 1, vec![2, 255, 1, 128]); + l.kind = 5; // indexed with alpha + img.layers.push(l); + let r = open(&img.write()).unwrap(); + assert_eq!(r.document.mode, ColorMode::Rgb); + assert_eq!(pixel(&r.document.layers[0], 0, 0), [0.0, 0.0, 1.0, 1.0]); + assert_eq!(pixel(&r.document.layers[0], 1, 0), [0.0, 1.0, 0.0, 128.0 / 255.0]); + assert!(r.warnings.iter().any(|w| w.contains("indexed"))); +} + +#[test] +fn channels_become_alpha_channels_and_the_selection() { + let mut img = TestImage::rgb8(4, 4); + img.layers.push(TestLayer::rgba("L", 4, 4, rgba(4, 4, |_, _| [0, 0, 0, 255]))); + img.channels.push(("Extra", (0..16).map(|i| i as u8 * 16).collect(), false)); + img.channels.push(("Selection Mask", vec![255; 16], true)); + let d = open(&img.write()).unwrap().document; + assert_eq!(d.channels.len(), 1); + assert_eq!(d.channels[0].name, "Extra"); + assert_eq!(d.channels[0].surface.read_region(Rect::new(3, 1, 4, 2))[0], 7.0 * 16.0 / 255.0); + assert_eq!(d.selection.as_ref().unwrap().read_region(Rect::new(0, 0, 1, 1))[0], 1.0); +} + +#[test] +fn image_parasites_resolution_guides_and_comment() { + let mut img = TestImage::rgb8(10, 10); + let mut res = Vec::new(); + res.extend_from_slice(&300.0f32.to_be_bytes()); + res.extend_from_slice(&300.0f32.to_be_bytes()); + img.image_props.push((19, res)); + let mut guides = Vec::new(); + guides.extend_from_slice(&4i32.to_be_bytes()); + guides.push(1); + guides.extend_from_slice(&7i32.to_be_bytes()); + guides.push(2); + img.image_props.push((18, guides)); + let mut parasites = Vec::new(); + for (name, data) in [("gimp-comment", &b"hello\0"[..]), ("icc-profile", &[1u8, 2, 3][..])] { + parasites.extend_from_slice(&(name.len() as u32 + 1).to_be_bytes()); + parasites.extend_from_slice(name.as_bytes()); + parasites.push(0); + parasites.extend_from_slice(&0u32.to_be_bytes()); + parasites.extend_from_slice(&(data.len() as u32).to_be_bytes()); + parasites.extend_from_slice(data); + } + img.image_props.push((21, parasites)); + // An unknown property is skipped by its length. + img.image_props.push((99, vec![1, 2, 3, 4, 5])); + img.layers.push(TestLayer::rgba("L", 10, 10, rgba(10, 10, |_, _| [0, 0, 0, 255]))); + let d = open(&img.write()).unwrap().document; + assert_eq!(d.resolution_dpi, 300.0); + assert_eq!((d.guides.horizontal.as_slice(), d.guides.vertical.as_slice()), (&[4.0][..], &[7.0][..])); + assert_eq!(d.metadata.text, vec![("Comment".to_string(), "hello".to_string())]); + assert_eq!(d.icc_profile.as_deref().map(|p| p.as_slice()), Some(&[1u8, 2, 3][..])); +} + +#[test] +fn clipping_text_vector_effects_and_floating_selections_are_reported() { + let mut img = TestImage::rgb8(4, 4); + img.version = 20; + let mut clipped = TestLayer::rgba("Clipped", 4, 4, rgba(4, 4, |_, _| [1, 1, 1, 255])); + let mut parasite = Vec::new(); + parasite.extend_from_slice(&("gimphoto-clipped".len() as u32 + 1).to_be_bytes()); + parasite.extend_from_slice(b"gimphoto-clipped\0"); + parasite.extend_from_slice(&0u32.to_be_bytes()); + parasite.extend_from_slice(&1u32.to_be_bytes()); + parasite.push(1); + clipped.props = vec![(21, parasite), (26, 1u32.to_be_bytes().to_vec()), (47, vec![0; 8])]; + img.layers.push(clipped); + let mut floating = TestLayer::rgba("Floating Selection", 2, 2, rgba(2, 2, |_, _| [2, 2, 2, 255])); + floating.props = vec![(5, vec![0; 8])]; + img.layers.push(floating); + img.layers.push(TestLayer::rgba("Bottom", 4, 4, rgba(4, 4, |_, _| [0, 0, 0, 255]))); + let r = open(&img.write()).unwrap(); + assert_eq!(r.document.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Bottom", "Clipped"], "the floating selection is left out"); + assert!(r.document.layers[1].clipped); + let w = r.warnings.join("\n"); + assert!(w.contains("text layer") && w.contains("vector layer") && w.contains("floating selection"), "{w}"); +} + +#[test] +fn version_3_files_use_32_bit_pointers_and_legacy_modes() { + let mut img = TestImage::rgb8(5, 5); + img.version = 3; + let mut l = TestLayer::rgba("Old", 5, 5, rgba(5, 5, |x, _| [x as u8, 0, 0, 255])); + l.mode = 3; // Multiply (legacy) + img.layers.push(l); + let bytes = img.write(); + assert!(bytes.starts_with(b"gimp xcf v003\0")); + let d = open(&bytes).unwrap().document; + assert_eq!((d.depth, d.layers[0].blend), (SampleType::U8, BlendMode::Multiply)); + assert_eq!(pixel(&d.layers[0], 4, 0)[0], 4.0 / 255.0); + // Version 0 ("file") as well. + let mut img0 = TestImage::rgb8(2, 2); + img0.version = 0; + img0.layers.push(TestLayer::rgba("Oldest", 2, 2, rgba(2, 2, |_, _| [7, 7, 7, 255]))); + assert!(open(&img0.write()).unwrap().document.layers.len() == 1); +} + +#[test] +fn rle_long_runs_and_literals_round_trip() { + // A tile with runs over 127 and long literal stretches, in every byte plane. + let w = 64; + let data = rgba(w, 64, |x, y| if y < 40 { [200, 1, 2, 255] } else { [(x * 7 + y) as u8, (x * 3) as u8, y as u8, 255] }); + let mut img = TestImage::rgb8(w, 64); + img.layers.push(TestLayer::rgba("R", w, 64, data.clone())); + let d = open(&img.write()).unwrap().document; + let got = pixel(&d.layers[0], 63, 63); + assert!((got[0] - (63 * 7 + 63) as u8 as f32 / 255.0).abs() < 1e-6 && (got[1] - 189.0 / 255.0).abs() < 1e-6, "{got:?}"); + assert_eq!(pixel(&d.layers[0], 10, 10), [200.0 / 255.0, 1.0 / 255.0, 2.0 / 255.0, 1.0]); +} + +#[test] +fn limits_and_malformed_structures_are_errors() { + // A canvas past the side limit. + let mut big = TestImage::rgb8(1, 1); + big.width = MAX_SIDE + 1; + assert!(open(&big.write()).is_err()); + // A layer whose declared size has no pixels behind it (hierarchy pointer to garbage). + let mut img = TestImage::rgb8(4, 4); + img.layers.push(TestLayer::rgba("L", 4, 4, rgba(4, 4, |_, _| [0; 4]))); + let mut bytes = img.write(); + let n = bytes.len(); + bytes[n - 10..].fill(0xff); + assert!(open(&bytes).is_err()); + // Wrong signature, unknown version tag, unknown compression. + assert!(matches!(open(b"gimp xcf vXYZ\0"), Err(IoError::Xcf(_)))); + assert!(open(b"not an xcf").is_err()); + let mut img = TestImage::rgb8(2, 2); + img.layers.push(TestLayer::rgba("L", 2, 2, rgba(2, 2, |_, _| [0; 4]))); + let mut bytes = img.write(); + let at = 14 + 12 + 4 + 8; + bytes[at] = 3; // compression byte: reserved + assert!(open(&bytes).is_err()); +} + +#[test] +fn gzipped_files_are_recognised_by_name_and_inflated() { + use std::io::Write; + let mut img = TestImage::rgb8(3, 3); + img.layers.push(TestLayer::rgba("L", 3, 3, rgba(3, 3, |_, _| [4, 5, 6, 255]))); + let plain = img.write(); + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(&plain).unwrap(); + let gz = e.finish().unwrap(); + assert!(is_gzipped_xcf("a/b/photo.XCF.GZ", &gz) && is_gzipped_xcf("x.xcfgz", &gz)); + assert!(!is_gzipped_xcf("photo.xcf", &gz) && !is_gzipped_xcf("photo.xcf.gz", &plain)); + let d = crate::import("photo.xcf.gz", &gz).unwrap().document; + assert_eq!(pixel(&d.layers[0], 1, 1), [4.0 / 255.0, 5.0 / 255.0, 6.0 / 255.0, 1.0]); + assert!(gunzip(&gz[..gz.len() / 2]).is_err() || crate::import("photo.xcf.gz", &gz[..gz.len() / 2]).is_err()); +} + +/// A recognized raster signature wins over a misleading `.xcf` suffix, as with `.kra` and `.svg`. +#[test] +fn a_png_with_an_xcf_suffix_opens_by_content() { + let img = + photocraft_codecs::Image::from_raw(12, 8, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, vec![90; 12 * 8 * 4]).unwrap(); + let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + let r = crate::import("renamed.xcf", &png).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (12, 8)); + assert!(crate::import("broken.xcf", b"not an image at all").is_err()); +} + +/// `PROP_FLOAT_OPACITY` wins over `PROP_OPACITY` whichever comes first in the file. +#[test] +fn float_opacity_wins_over_opacity_in_either_order() { + let mut img = TestImage::rgb8(4, 4); + // The writer emits OPACITY (0.4 → 102) then FLOAT_OPACITY 0.4; an OPACITY 255 after them must not win. + let mut byte_last = TestLayer::rgba("Float then byte", 4, 4, rgba(4, 4, |_, _| [1, 2, 3, 255])); + byte_last.opacity = 0.4; + byte_last.props = vec![(6, 255u32.to_be_bytes().to_vec())]; + // A later FLOAT_OPACITY replaces the earlier one. + let mut float_last = TestLayer::rgba("Byte then float", 4, 4, rgba(4, 4, |_, _| [1, 2, 3, 255])); + float_last.opacity = 0.4; + float_last.props = vec![(33, 0.25f32.to_be_bytes().to_vec())]; + img.layers.push(byte_last); + img.layers.push(float_last); + let d = open(&img.write()).unwrap().document; + assert_eq!(layer_at(&d.layers, "Float then byte").opacity, 0.4); + assert_eq!(layer_at(&d.layers, "Byte then float").opacity, 0.25); +} + +/// A path that fails below the top level (here at depth 2) still lands at the top level, so the +/// group it would have been pushed into keeps its indices for the siblings placed after it. +#[test] +fn a_path_failing_below_the_top_level_lands_at_the_top_level() { + let mut img = TestImage::rgb8(8, 8); + let px = |v: u8| rgba(2, 2, move |_, _| [v, v, v, 255]); + let mut group = TestLayer::rgba("Group", 1, 1, Vec::new()); + group.props = vec![(29, vec![]), (31, 0u32.to_be_bytes().to_vec())]; + img.layers.push(group); + let mut stray = TestLayer::rgba("Stray", 2, 2, px(5)); + stray.props = vec![(30, [0u32, 4, 0].iter().flat_map(|v| v.to_be_bytes()).collect())]; + img.layers.push(stray); + let mut a = TestLayer::rgba("A", 2, 2, px(6)); + a.props = vec![(30, [0u32, 0].iter().flat_map(|v| v.to_be_bytes()).collect())]; + img.layers.push(a); + img.layers.push(TestLayer::rgba("Bottom", 8, 8, rgba(8, 8, |_, _| [0, 0, 0, 255]))); + let r = open(&img.write()).unwrap(); + let d = &r.document; + assert_eq!(d.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Bottom", "Stray", "Group"]); + let children = layer_at(&d.layers, "Group").children().unwrap().iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(children, ["A"], "the valid sibling placed after the stray one still lands in the group"); + assert!(r.warnings.iter().any(|w| w.contains("Stray") && w.contains("item path")), "{:?}", r.warnings); +} + +/// A parasite name field: u32 `len+1`, then the bytes and a trailing NUL. +fn parasite_name(out: &mut Vec, name: &str) { + out.extend_from_slice(&((name.len() as u32) + 1).to_be_bytes()); + out.extend_from_slice(name.as_bytes()); + out.push(0); +} + +/// One PROP_PARASITES record: name, u32 flags, u32 len, then the payload. +fn parasite_record(out: &mut Vec, name: &str, payload: &[u8]) { + parasite_name(out, name); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(payload); +} + +#[test] +fn duplicate_parasites_keep_order_and_last_value() { + // Duplicate names must resolve to the last payload while keeping first-seen order. + // This is the behavior the name->index map must preserve versus the old linear scan. + let mut data = Vec::new(); + parasite_record(&mut data, "gimp-comment", b"first"); + parasite_record(&mut data, "gimp-image-grid", b"grid"); + parasite_record(&mut data, "gimp-comment", b"second"); + let out = parasites(&data); + assert_eq!(out.len(), 2, "duplicate collapses to one slot"); + assert_eq!(out[0].0, "gimp-comment"); + assert_eq!(out[0].1, b"second", "last value wins"); + assert_eq!(out[1].0, "gimp-image-grid", "insertion order preserved"); +} diff --git a/crates/io/tests/adjust_blend_roundtrip.rs b/crates/io/tests/it/adjust_blend_roundtrip.rs similarity index 99% rename from crates/io/tests/adjust_blend_roundtrip.rs rename to crates/io/tests/it/adjust_blend_roundtrip.rs index 4e8b727af2..67085abee3 100644 --- a/crates/io/tests/adjust_blend_roundtrip.rs +++ b/crates/io/tests/it/adjust_blend_roundtrip.rs @@ -4,7 +4,7 @@ //! document flattens to the same composite (within 1/255) in every case, so nothing a user made //! in PhotoCraft silently disappears or changes when Photoshop opens the file. -mod common; +use crate::common; use std::sync::Arc; diff --git a/crates/io/tests/adjust_warnings.rs b/crates/io/tests/it/adjust_warnings.rs similarity index 100% rename from crates/io/tests/adjust_warnings.rs rename to crates/io/tests/it/adjust_warnings.rs diff --git a/crates/io/tests/affinity.rs b/crates/io/tests/it/affinity.rs similarity index 100% rename from crates/io/tests/affinity.rs rename to crates/io/tests/it/affinity.rs diff --git a/crates/io/tests/affinity_artboards.rs b/crates/io/tests/it/affinity_artboards.rs similarity index 98% rename from crates/io/tests/affinity_artboards.rs rename to crates/io/tests/it/affinity_artboards.rs index 67e3739043..26a9a3c7e5 100644 --- a/crates/io/tests/affinity_artboards.rs +++ b/crates/io/tests/it/affinity_artboards.rs @@ -1,7 +1,7 @@ //! Current .af artboards stay separate, editable and clipped through native/PSD saves. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -#[path = "support/current_affinity.rs"] +#[path = "../support/current_affinity.rs"] mod fixture; use fixture::{Marker, document}; diff --git a/crates/io/tests/affinity_corpus.rs b/crates/io/tests/it/affinity_corpus.rs similarity index 100% rename from crates/io/tests/affinity_corpus.rs rename to crates/io/tests/it/affinity_corpus.rs diff --git a/crates/io/tests/annotations.rs b/crates/io/tests/it/annotations.rs similarity index 100% rename from crates/io/tests/annotations.rs rename to crates/io/tests/it/annotations.rs diff --git a/crates/io/tests/artboard_guide_order.rs b/crates/io/tests/it/artboard_guide_order.rs similarity index 100% rename from crates/io/tests/artboard_guide_order.rs rename to crates/io/tests/it/artboard_guide_order.rs diff --git a/crates/io/tests/banded.rs b/crates/io/tests/it/banded.rs similarity index 100% rename from crates/io/tests/banded.rs rename to crates/io/tests/it/banded.rs diff --git a/crates/io/tests/cancel.rs b/crates/io/tests/it/cancel.rs similarity index 100% rename from crates/io/tests/cancel.rs rename to crates/io/tests/it/cancel.rs diff --git a/crates/io/tests/channels.rs b/crates/io/tests/it/channels.rs similarity index 100% rename from crates/io/tests/channels.rs rename to crates/io/tests/it/channels.rs diff --git a/crates/io/tests/cmyk_composite_export.rs b/crates/io/tests/it/cmyk_composite_export.rs similarity index 100% rename from crates/io/tests/cmyk_composite_export.rs rename to crates/io/tests/it/cmyk_composite_export.rs diff --git a/crates/io/tests/color.rs b/crates/io/tests/it/color.rs similarity index 99% rename from crates/io/tests/color.rs rename to crates/io/tests/it/color.rs index c5d4479904..b553097ae4 100644 --- a/crates/io/tests/color.rs +++ b/crates/io/tests/it/color.rs @@ -1,7 +1,7 @@ //! ICC profiles through import/export: byte-exact PSD round trips with real profiles, PNG/JPEG //! embedding and extraction, colour-managed CMYK → RGB for formats without CMYK. -mod common; +use crate::common; use std::sync::Arc; diff --git a/crates/io/tests/common/mod.rs b/crates/io/tests/it/common/mod.rs similarity index 100% rename from crates/io/tests/common/mod.rs rename to crates/io/tests/it/common/mod.rs diff --git a/crates/io/tests/composite.rs b/crates/io/tests/it/composite.rs similarity index 99% rename from crates/io/tests/composite.rs rename to crates/io/tests/it/composite.rs index fbb9e5ab3d..cef80e6a08 100644 --- a/crates/io/tests/composite.rs +++ b/crates/io/tests/it/composite.rs @@ -1,7 +1,7 @@ //! Composite oracle: the merged image stored in a PSD must match our //! compositor's rendering of the imported document. -mod common; +use crate::common; use common::*; use photocraft_color::{BlendMode, ColorMode, SampleType}; diff --git a/crates/io/tests/comps_artboards.rs b/crates/io/tests/it/comps_artboards.rs similarity index 96% rename from crates/io/tests/comps_artboards.rs rename to crates/io/tests/it/comps_artboards.rs index 346d7cc06a..a5b553f5a8 100644 --- a/crates/io/tests/comps_artboards.rs +++ b/crates/io/tests/it/comps_artboards.rs @@ -182,7 +182,15 @@ fn synthetic_psd_roundtrip_all_depths() { v.sort_by(|x, y| x.0.cmp(&y.0)); v }; - assert_eq!(named(&d, a), named(&back, b), "{depth:?} comp {}", a.name); + let mut want = named(&d, a); + if a.name == "One" { + // "One" records nothing for Board 1. A PSD can't say "leaves it alone": its entry + // has no `enab`, which Photoshop reads as the previous comp's visibility (Both: + // shown), and so does the import now (#8093). + want.push(("Board 1".to_string(), Some(true), None)); + want.sort_by(|x, y| x.0.cmp(&y.0)); + } + assert_eq!(want, named(&back, b), "{depth:?} comp {}", a.name); } // Re-exporting the imported document is byte-stable for comps and artboards. let f1 = PsdFile::from_bytes(&to_psd(&back)).unwrap(); diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/it/corpus.rs similarity index 99% rename from crates/io/tests/corpus.rs rename to crates/io/tests/it/corpus.rs index 710b2a8644..b7695d451a 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/it/corpus.rs @@ -44,7 +44,7 @@ //! asserts import + flatten return (Ok or Err) without panicking. #![cfg(feature = "corpus")] -mod common; +use crate::common; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; @@ -156,7 +156,7 @@ const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-t /// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus), /// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/`). -const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 188, roundtrip_floor: 258 }; +const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/photoshop", group_depth: 2, pass_floor: 190, roundtrip_floor: 258 }; /// The corpus directory; a missing corpus fails the test. fn locate(src: &Source) -> PathBuf { diff --git a/crates/io/tests/corpus_regressions.rs b/crates/io/tests/it/corpus_regressions.rs similarity index 94% rename from crates/io/tests/corpus_regressions.rs rename to crates/io/tests/it/corpus_regressions.rs index 0555fc47ca..26266c9f07 100644 --- a/crates/io/tests/corpus_regressions.rs +++ b/crates/io/tests/it/corpus_regressions.rs @@ -1,5 +1,5 @@ //! Synthetic reproductions of failures found by the psd-tools corpus -//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus +//! (`cargo xtask corpus --psd-tools`; see `tests/it/corpus.rs`). The corpus //! files themselves are never committed: each test rebuilds the relevant //! structure from our own documents. @@ -116,9 +116,10 @@ fn real_user_mask_with_parameters_is_decoded() { /// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose /// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the -/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask. +/// stored pixels hold only the shape. It imports as a shape with that density (#5846), rendered +/// from its path rather than from the stored pixels. #[test] -fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() { +fn shape_with_vector_mask_density_imports_as_soft_shape() { let mut d = doc(SampleType::U8); let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK))); let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0)); @@ -138,8 +139,9 @@ fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() { } let imp = reimport(&file); let l = &imp.document.layers[1]; - assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name()); - assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0)); + let LayerContent::Shape(sh) = &l.content else { panic!("{}", l.content.kind_name()) }; + assert_eq!(sh.density, 204.0 / 255.0); + assert!(l.vector_mask.is_none()); let f = photocraft_compose::flatten(&imp.document); assert!(f.get(16, 16)[0] < 0.01, "inside the path: black"); assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2)); diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/it/effects_multi.rs similarity index 94% rename from crates/io/tests/effects_multi.rs rename to crates/io/tests/it/effects_multi.rs index 4eda6ad81b..637e926085 100644 --- a/crates/io/tests/effects_multi.rs +++ b/crates/io/tests/it/effects_multi.rs @@ -8,7 +8,7 @@ use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, export, import}; fn stroke(size: f32, position: StrokePosition, paint: FxPaint, opacity: f32) -> Effect { - Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint }) + Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint, overprint: false }) } fn roundtrip(doc: &Document) -> Document { @@ -65,7 +65,8 @@ fn joined_shape_components_roundtrip() { let outer = Subpath::polygon(&[(5.0, 5.0), (35.0, 5.0), (35.0, 35.0), (5.0, 35.0)]); let hole = Subpath::polygon(&[(12.0, 12.0), (12.0, 28.0), (28.0, 28.0), (28.0, 12.0)]).with_op(PathOp::Join); let path = Path::new(vec![outer, hole]); - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None }; + let mut sh = + ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, d.bounds())); let mut l = Layer::new("ring", LayerContent::Shape(sh)); l.effects.items = vec![stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), 1.0)]; diff --git a/crates/io/tests/exif_resolution.rs b/crates/io/tests/it/exif_resolution.rs similarity index 100% rename from crates/io/tests/exif_resolution.rs rename to crates/io/tests/it/exif_resolution.rs diff --git a/crates/io/tests/export_progress.rs b/crates/io/tests/it/export_progress.rs similarity index 100% rename from crates/io/tests/export_progress.rs rename to crates/io/tests/it/export_progress.rs diff --git a/crates/io/tests/exr_part_profile.rs b/crates/io/tests/it/exr_part_profile.rs similarity index 100% rename from crates/io/tests/exr_part_profile.rs rename to crates/io/tests/it/exr_part_profile.rs diff --git a/crates/io/tests/flat.rs b/crates/io/tests/it/flat.rs similarity index 99% rename from crates/io/tests/flat.rs rename to crates/io/tests/it/flat.rs index 72126d0bc0..353e2bbb7a 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/it/flat.rs @@ -1,6 +1,6 @@ //! Flat formats through photocraft-codecs, and export warnings. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; @@ -56,6 +56,9 @@ codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); +// Cineon stores 10-bit code values: within half a 10-bit step. +codec_rt!(cin_gray16, "cin", ColorMode::Grayscale, SampleType::U16, false, 0.5 / 1023.0); +codec_rt!(cin_rgb16, "cin", ColorMode::Rgb, SampleType::U16, false, 0.5 / 1023.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; @@ -1173,7 +1176,7 @@ fn a_lab_document_with_one_layer_is_not_reported_as_flattened() { fn a_stereo_png_keeps_its_stereo_mark_on_save() { use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, PngStereo, SampleType as CS}; let img = Image::from_raw(4, 2, ChannelLayout::Rgb, CS::U8, vec![40u8; 24]).unwrap(); - for view in [PngStereo::Horizontal, PngStereo::Vertical] { + for view in [PngStereo::CrossFuse, PngStereo::DivergingFuse] { let bytes = photocraft_codecs::encode(&img, Format::Png, &EncodeOptions { png_stereo: Some(view), ..Default::default() }).unwrap(); let doc = import("stereo.png", &bytes).unwrap().document; assert_eq!(doc.file_encoding.png_stereo, Some(view.mode())); diff --git a/crates/io/tests/gif_frames.rs b/crates/io/tests/it/gif_frames.rs similarity index 100% rename from crates/io/tests/gif_frames.rs rename to crates/io/tests/it/gif_frames.rs diff --git a/crates/io/tests/guides_v2.rs b/crates/io/tests/it/guides_v2.rs similarity index 100% rename from crates/io/tests/guides_v2.rs rename to crates/io/tests/it/guides_v2.rs diff --git a/crates/io/tests/heif.rs b/crates/io/tests/it/heif.rs similarity index 100% rename from crates/io/tests/heif.rs rename to crates/io/tests/it/heif.rs diff --git a/crates/io/tests/heif_unsupported.rs b/crates/io/tests/it/heif_unsupported.rs similarity index 100% rename from crates/io/tests/heif_unsupported.rs rename to crates/io/tests/it/heif_unsupported.rs diff --git a/crates/io/tests/krita_corpus.rs b/crates/io/tests/it/krita_corpus.rs similarity index 97% rename from crates/io/tests/krita_corpus.rs rename to crates/io/tests/it/krita_corpus.rs index b0b3a01d63..ab2c56c143 100644 --- a/crates/io/tests/krita_corpus.rs +++ b/crates/io/tests/it/krita_corpus.rs @@ -2,7 +2,7 @@ //! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are //! not bundled. Run with -//! `KRITA_TEST_DIR= cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture`. +//! `KRITA_TEST_DIR= cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture`. use std::path::{Path, PathBuf}; diff --git a/crates/io/tests/lab16.rs b/crates/io/tests/it/lab16.rs similarity index 100% rename from crates/io/tests/lab16.rs rename to crates/io/tests/it/lab16.rs diff --git a/crates/io/tests/it/large_files.rs b/crates/io/tests/it/large_files.rs new file mode 100644 index 0000000000..b0a2669480 --- /dev/null +++ b/crates/io/tests/it/large_files.rs @@ -0,0 +1,166 @@ +//! Large-file fixtures (#216, #5107), generated at test time by `photocraft_psd::fixtures`: a 20 MP +//! layered PSD, a 150-layer layout, a PSD just under Photoshop's 2 GB cap and a 4.4 GB PSB. Each +//! is opened through `photocraft_io`, checked (size, layers, masks, effects, merged pixels), saved +//! again and re-opened. +//! +//! The two layered documents run in memory; the streamed files are written to +//! `$PHOTOCRAFT_FIXTURE_DIR` (default: the system temp dir), removed afterwards, and are opt-in: +//! `cargo test --release -p photocraft-io --test it -- large_files:: --ignored`. + +use std::path::PathBuf; + +use photocraft_doc::{Document, LayerContent}; +use photocraft_geom::Rect; +use photocraft_psd::Version; +use photocraft_psd::fixtures::{Grid, LAYERED_20MP, LAYOUT_150, PSB_4GB, PSD_2GB, Streamed}; + +/// Raster layers, groups, masked layers, layers with effects and hidden layers in `doc`. +fn census(doc: &Document) -> [usize; 5] { + let mut n = [0; 5]; + for (_, _, l) in doc.walk() { + match l.content { + LayerContent::Raster(_) => n[0] += 1, + LayerContent::Group(_) => n[1] += 1, + _ => {} + } + n[2] += usize::from(l.mask.is_some()); + n[3] += usize::from(!l.effects.items.is_empty()); + n[4] += usize::from(!l.visible); + } + n +} + +fn check_grid(g: &Grid, doc: &Document, what: &str) { + assert_eq!((doc.size.width, doc.size.height), (g.width, g.height), "{what}"); + let layers: Vec<_> = (0..g.layers).filter_map(|i| g.layer(i)).collect(); + let want = [ + g.layers as usize + 1, + g.rows() as usize, + layers.iter().filter(|l| l.masked).count(), + layers.iter().filter(|l| l.shadow).count(), + layers.iter().filter(|l| l.hidden).count(), + ]; + assert_eq!(census(doc), want, "{what}: [raster, groups, masked, with effects, hidden]"); + for (x, y) in g.sample_points() { + let px = photocraft_compose::render(doc, Rect::from_xywh(x as i32, y as i32, 1, 1)).px; + let got = px.first().copied().unwrap_or_default(); + let want = g.expected(x, y); + for c in 0..3 { + let v = (got[c] * 255.0).round() as i32; + assert!((v - i32::from(want[c])).abs() <= 1, "{what}: ({x}, {y}) channel {c}: got {v}, want {}", want[c]); + } + assert!(got[3] > 0.999, "{what}: ({x}, {y}) is not opaque"); + } +} + +/// Builds `g`, opens it, checks it, saves it as PSD and checks the re-opened file. +fn grid_roundtrip(g: Grid, what: &str) { + let t = std::time::Instant::now(); + let bytes = g.build(Version::Psd).and_then(|f| f.to_bytes()).unwrap(); + let gen_s = t.elapsed().as_secs_f64(); + let t = std::time::Instant::now(); + let res = photocraft_io::import("fixture.psd", &bytes).unwrap(); + let open_s = t.elapsed().as_secs_f64(); + check_grid(&g, &res.document, what); + let out = photocraft_io::export(&res.document, "fixture.psd", &Default::default()).unwrap(); + let again = photocraft_io::import("fixture.psd", &out.bytes).unwrap(); + check_grid(&g, &again.document, &format!("{what}, saved and re-opened")); + eprintln!( + "{what}: {:.1} MB, generated in {gen_s:.2} s, opened in {open_s:.2} s, saved as {:.1} MB", + bytes.len() as f64 / 1e6, + out.bytes.len() as f64 / 1e6 + ); +} + +/// 150 layers in 15 groups on A4 at 300 ppi, with masks, hidden layers, closed groups and drop +/// shadows. +#[test] +fn layout_with_150_layers_opens_and_saves() { + grid_roundtrip(LAYOUT_150, "150-layer layout"); +} + +/// A 20 MP layered photo: background, five layers in two groups, a mask and a drop shadow. +#[test] +fn layered_20_mp_psd_opens_and_saves() { + grid_roundtrip(LAYERED_20MP, "20 MP layered"); +} + +fn fixture_dir() -> PathBuf { + let dir = std::env::var_os("PHOTOCRAFT_FIXTURE_DIR").map_or_else(std::env::temp_dir, PathBuf::from).join("photocraft-large-files"); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Removes the file when the test ends, passed or not. +struct Remove(PathBuf); + +impl Drop for Remove { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +fn check_streamed(case: &Streamed, doc: &Document, what: &str) { + assert_eq!((doc.size.width, doc.size.height), (case.width, case.height), "{what}"); + assert_eq!(doc.layers.len(), 1, "{what}"); + let s = doc.layers[0].surface().unwrap(); + let (w, h) = (case.width, case.height); + for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1), (w / 2, h / 2), (w / 3, h * 2 / 3), (12_345 % w, h - 7)] { + let got = s.pixel(x as i32, y as i32); + assert_eq!(got.len(), 4, "{what}"); + for (c, v) in got.iter().enumerate() { + let want = f32::from(case.sample(x, y, c as u32)) / 255.0; + assert!((v - want).abs() <= 1.0 / 510.0, "{what}: ({x}, {y}) channel {c}: got {v}, want {want}"); + } + } +} + +/// Writes `case`, opens it, checks it, saves it again (the writer picks PSD or PSB) and checks the +/// re-opened file. +fn streamed_roundtrip(case: &Streamed) { + let path = fixture_dir().join(case.file_name()); + let _cleanup = Remove(path.clone()); + let t = std::time::Instant::now(); + let len = case.write_file(&path).unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().len(), len); + eprintln!("{}: {:.2} GB written in {:.1} s", case.name, len as f64 / 1e9, t.elapsed().as_secs_f64()); + + let t = std::time::Instant::now(); + let bytes = std::fs::read(&path).unwrap(); + let res = photocraft_io::import(&case.file_name(), &bytes).unwrap(); + drop(bytes); + eprintln!("{}: read and opened in {:.1} s, warnings {:?}", case.name, t.elapsed().as_secs_f64(), res.warnings); + check_streamed(case, &res.document, case.name); + + let t = std::time::Instant::now(); + let out = photocraft_io::export(&res.document, &case.file_name(), &Default::default()).unwrap(); + drop(res); + // Version 2 = PSB: a re-save past 2 GB must switch to PSB; a smaller one stays PSD. + let psb = out.bytes.get(4..6) == Some(&[0, 2]); + assert!(psb || (case.version == Version::Psd && (out.bytes.len() as u64) < photocraft_psd::fixtures::PSD_MAX_FILE_BYTES), "{}", case.name); + eprintln!( + "{}: saved {:.2} GB ({}) in {:.1} s, warnings {:?}", + case.name, + out.bytes.len() as f64 / 1e9, + if psb { "PSB" } else { "PSD" }, + t.elapsed().as_secs_f64(), + out.warnings + ); + let again = photocraft_io::import(if psb { "again.psb" } else { "again.psd" }, &out.bytes).unwrap(); + drop(out); + check_streamed(case, &again.document, &format!("{}, saved and re-opened", case.name)); +} + +/// A 17,000² layered PSD of 2.02 GB, just under Photoshop's 2 GB PSD cap. +#[test] +#[ignore = "writes a 2 GB file and needs ~12 GB of memory; run in release"] +fn psd_just_under_2_gb_opens_and_saves() { + streamed_roundtrip(&PSD_2GB); +} + +/// A 25,000² layered PSB of 4.38 GB: layer channels of 625 MB, data past 4 GiB (#375). +#[test] +#[ignore = "writes a 4.4 GB file and needs ~25 GB of memory; run in release"] +fn psb_of_4_gb_opens_and_saves() { + streamed_roundtrip(&PSB_4GB); +} diff --git a/crates/io/tests/large_psb.rs b/crates/io/tests/it/large_psb.rs similarity index 96% rename from crates/io/tests/large_psb.rs rename to crates/io/tests/it/large_psb.rs index bcde955bd0..da9f6ca9a1 100644 --- a/crates/io/tests/large_psb.rs +++ b/crates/io/tests/it/large_psb.rs @@ -25,7 +25,7 @@ fn undecodable_channel_leaves_the_layer_empty() { /// A flattened 29000² RGB PSB: its merged image decodes to 2.52 GB, past the old 2 GiB cap, which /// opened it as a document with no layers. Needs about 12 GB of memory, so it is opt-in: -/// `cargo test --release -p photocraft-io --test large_psb -- --ignored`. +/// `cargo test --release -p photocraft-io --test it -- large_psb:: --ignored`. #[test] #[ignore = "allocates ~12 GB; run in release"] fn flattened_psb_with_a_merged_image_over_2_gib_opens() { diff --git a/crates/io/tests/layer_labels.rs b/crates/io/tests/it/layer_labels.rs similarity index 100% rename from crates/io/tests/layer_labels.rs rename to crates/io/tests/it/layer_labels.rs diff --git a/crates/io/tests/it/layer_xmp.rs b/crates/io/tests/it/layer_xmp.rs new file mode 100644 index 0000000000..87a02b0876 --- /dev/null +++ b/crates/io/tests/it/layer_xmp.rs @@ -0,0 +1,102 @@ +//! A layer's own XMP packet (`Layer::xmp`, PSD `shmd` › `cust` › `layerXMP`) survives every +//! layered format and keeps the rest of a Photoshop `shmd` block as it was. + +use std::sync::Arc; + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, Size}; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::PsdFile; +use photocraft_psd::metadata::{layer_xmp, put_layer_xmp}; + +const A: &str = r#"120"#; +const G: &str = r#"Café 🎨"#; + +/// `shmd` of a Photoshop 2026 layer without XMP: a `cust` item holding only `layerTime`. +const PS_WITHOUT_XMP: &str = + "000000013842494d63757374000000000000003400000010000000010000000000086d6574616461746100000001000000096c6179657254696d65646f756241dab643ffd86bec00"; + +fn unhex(h: &str) -> Vec { + (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() +} + +fn xmps(d: &Document) -> Vec<(String, Option)> { + d.walk().into_iter().map(|(_, _, l)| (l.name.clone(), l.xmp.as_deref().map(str::to_string))).collect() +} + +fn layer_shmd<'a>(f: &'a PsdFile, name: &str) -> Option<&'a [u8]> { + f.layers().iter().find(|r| r.name() == name).and_then(|r| r.block(b"shmd")).map(|b| b.data.as_slice()) +} + +fn sample(depth: SampleType) -> Document { + let mut doc = Document::new("Layer XMP", Size::new(4, 4), ColorMode::Rgb, depth); + let mut a = Layer::raster("A", doc.pixel_format()); + a.xmp = Some(Arc::from(A)); + let plain = Layer::raster("Plain", doc.pixel_format()); + let mut g = Layer::group("G", vec![plain]); + g.xmp = Some(Arc::from(G)); + doc.layers.extend([a, g]); + doc +} + +#[test] +fn layer_xmp_round_trips_through_every_layered_format_and_depth() { + let psb = ExportOptions { force_psb: true, ..Default::default() }; + let tiff = ExportOptions { tiff_layers: true, ..Default::default() }; + for depth in SampleType::ALL { + let doc = sample(depth); + for (path, opts) in [("x.psd", &ExportOptions::default()), ("x.psb", &psb), ("x.tif", &tiff), ("x.pcraft", &ExportOptions::default())] { + let bytes = export(&doc, path, opts).unwrap().bytes; + let back = import(path, &bytes).unwrap().document; + assert_eq!(xmps(&back), xmps(&doc), "{path} {depth:?}"); + } + // Stored as Photoshop stores it; a layer without XMP gets no block. + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let f = PsdFile::from_bytes(&bytes).unwrap(); + assert_eq!(layer_shmd(&f, "A").and_then(layer_xmp).as_deref(), Some(A)); + assert_eq!(layer_shmd(&f, "G").and_then(layer_xmp).as_deref(), Some(G)); + assert!(layer_shmd(&f, "Plain").is_none()); + } +} + +#[test] +fn photoshop_shmd_keeps_its_other_data() { + let without = unhex(PS_WITHOUT_XMP); + let with = put_layer_xmp(Some(&without), A).unwrap(); + // A layer as an older PhotoCraft kept it: Photoshop's block verbatim, no field. + let mut doc = Document::new("PS", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + let mut l = Layer::raster("L", doc.pixel_format()); + l.psd_blocks.push((*b"shmd", Arc::new(with.clone()))); + doc.layers.push(l); + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + + // Opening it gives the packet to the field and keeps the rest of the block. + let mut back = import("x.psd", &bytes).unwrap().document; + let l = &back.layers[0]; + assert_eq!(l.xmp.as_deref(), Some(A)); + assert_eq!(l.psd_blocks.iter().find(|(k, _)| k == b"shmd").map(|(_, d)| d.as_slice()), Some(without.as_slice())); + + // Unchanged, the block is written back byte for byte. + let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(layer_shmd(&PsdFile::from_bytes(&again).unwrap(), "L"), Some(with.as_slice())); + + // Edited, `layerTime` stays; removed, the block is Photoshop's block without XMP. + back.layers[0].xmp = Some(Arc::from(G)); + let edited = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(layer_shmd(&PsdFile::from_bytes(&edited).unwrap(), "L"), Some(put_layer_xmp(Some(&without), G).unwrap().as_slice())); + back.layers[0].xmp = None; + let removed = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(layer_shmd(&PsdFile::from_bytes(&removed).unwrap(), "L"), Some(without.as_slice())); +} + +#[test] +fn an_unreadable_shmd_is_kept_and_reported() { + let mut doc = Document::new("Bad", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + let mut l = Layer::raster("L", doc.pixel_format()); + l.psd_blocks.push((*b"shmd", Arc::new(vec![0, 0, 0, 9]))); + l.xmp = Some(Arc::from(A)); + doc.layers.push(l); + let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap(); + assert!(out.warnings.iter().any(|w| w.contains("XMP metadata was not saved")), "{:?}", out.warnings); + assert_eq!(layer_shmd(&PsdFile::from_bytes(&out.bytes).unwrap(), "L"), Some([0, 0, 0, 9].as_slice())); +} diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs new file mode 100644 index 0000000000..662d37c307 --- /dev/null +++ b/crates/io/tests/it/main.rs @@ -0,0 +1,76 @@ +//! The crate's integration tests, linked into one test binary. + +mod adjust_blend_roundtrip; +mod adjust_warnings; +mod affinity; +mod affinity_artboards; +mod affinity_corpus; +mod annotations; +mod artboard_guide_order; +mod banded; +mod cancel; +mod channels; +mod cmyk_composite_export; +mod color; +mod common; +mod composite; +mod comps_artboards; +mod corpus; +mod corpus_regressions; +mod effects_multi; +mod exif_resolution; +mod export_progress; +mod exr_part_profile; +mod flat; +mod gif_frames; +mod guides_v2; +mod heif; +mod heif_unsupported; +mod krita_corpus; +mod lab16; +mod large_files; +mod large_psb; +mod layer_labels; +mod layer_xmp; +mod modes; +mod multichannel; +mod orientation; +mod pattern_fill_psd; +mod pcraft_manifest_name; +mod pdn_corpus; +mod png_text_metadata; +mod psd16_merged_transparency; +mod psd32_export; +mod psd_background; +mod psd_block_alignment; +mod psd_block_layout; +mod psd_damaged_merged; +mod psd_empty_channels; +mod psd_font_identity; +mod psd_kerning; +mod psd_layer_block_length; +mod psd_maximize_compatibility; +mod psd_noise_fill_roundtrip; +mod psd_roundtrip_bytes; +mod psd_structure; +mod psd_style_sheets; +mod psd_thumbnail; +mod psd_type_bounds; +mod psd_type_photoshop_bytes; +mod psd_unknown_adjustment; +mod psd_vertical_type; +mod raw; +mod resolution_axes; +mod roundtrip; +mod smart_contents_identity; +mod smart_corpus; +mod svg; +mod text_corpus; +mod text_styles; +mod tiff_corpus; +mod tiff_layers; +mod tiff_pages; +mod tiff_type_geometry; +mod vector; +mod xcf; +mod xmp_export; diff --git a/crates/io/tests/modes.rs b/crates/io/tests/it/modes.rs similarity index 74% rename from crates/io/tests/modes.rs rename to crates/io/tests/it/modes.rs index 6760c7afe7..b1bfff3d30 100644 --- a/crates/io/tests/modes.rs +++ b/crates/io/tests/it/modes.rs @@ -152,3 +152,60 @@ fn indexed_png_keeps_the_document_resolution() { let back = photocraft_io::import("x.png", &r.bytes).unwrap().document; assert!((back.resolution_dpi - 300.0).abs() < 0.01, "{}", back.resolution_dpi); } + +#[test] +fn indexed_bmp_is_palette_bmp() { + // The BMP export wrote an Indexed Color document as 24-bit RGB, so reopening it lost the mode (#3869 follow-up). + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]], transparent: None }); + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + assert_eq!(u16::from_le_bytes([r.bytes[28], r.bytes[29]]), 8, "8 bits per pixel"); + assert!(r.warnings.iter().any(|w| w.contains("palette BMP")), "{:?}", r.warnings); + let back = photocraft_io::import("x.bmp", &r.bytes).unwrap().document; + assert_eq!(back.mode, ColorMode::Indexed); + assert_eq!(back.color_table.as_ref().map(|t| t.colors.clone()), Some(vec![[0, 0, 0], [255, 0, 0], [0, 255, 0]])); + let px = back.layers[0].surface().unwrap().rgba(1, 1); + assert_eq!(&px[..3], &[1.0, 0.0, 0.0]); +} + +#[test] +fn indexed_bmp_warns_that_it_has_no_transparency() { + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0]], transparent: Some(0) }); + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("no transparency")), "{:?}", r.warnings); +} + +#[test] +fn indexed_bmp_exports_when_its_only_palette_entry_is_marked_transparent() { + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[255, 0, 0]], transparent: Some(0) }); + + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + + assert_eq!(u16::from_le_bytes([r.bytes[28], r.bytes[29]]), 8, "8 bits per pixel"); + let back = photocraft_io::import("x.bmp", &r.bytes).unwrap().document; + assert_eq!(&back.layers[0].surface().unwrap().rgba(1, 1)[..3], &[1.0, 0.0, 0.0]); +} + +#[test] +fn indexed_bmp_matches_opaque_pixels_against_the_transparent_palette_entry() { + let mut d = doc(ColorMode::Indexed); + d.color_table = Some(ColorTable { colors: vec![[255, 0, 0], [0, 0, 255]], transparent: Some(0) }); + + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + + let back = photocraft_io::import("x.bmp", &r.bytes).unwrap().document; + assert_eq!(&back.layers[0].surface().unwrap().rgba(1, 1)[..3], &[1.0, 0.0, 0.0]); +} + +#[test] +fn indexed_bmp_without_a_usable_table_falls_back_to_rgb() { + // No table (or one past 256 entries) keeps the old 24-bit path rather than failing the save. + for table in [None, Some(ColorTable { colors: vec![[0; 3]; 257], transparent: None })] { + let mut d = doc(ColorMode::Indexed); + d.color_table = table; + let r = photocraft_io::export(&d, "x.bmp", &Default::default()).unwrap(); + assert_eq!(u16::from_le_bytes([r.bytes[28], r.bytes[29]]), 24); + } +} diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/it/multichannel.rs similarity index 61% rename from crates/io/tests/multichannel.rs rename to crates/io/tests/it/multichannel.rs index d6016070d1..fd8f8448c3 100644 --- a/crates/io/tests/multichannel.rs +++ b/crates/io/tests/it/multichannel.rs @@ -46,10 +46,21 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { assert!((a.opacity - 0.5).abs() < 1e-6); assert_eq!(a.indicates, ColorIndicates::MaskedAreas); - // It still prints as black ink, so the composite is what a black spot channel gives. + // It still prints as black ink, so the composite is what a black spot channel gives. The + // alpha is stored as-is (1 = selected), so that ink is its inverse, as on the PSD plane. let mut as_ink = d.clone(); + let canvas = d.bounds(); + let ink: Vec = d.channels[1].surface.read_region(canvas).iter().map(|v| 1.0 - v).collect(); + let mut s = photocraft_raster::Surface::new(d.channels[1].surface.format()); + s.write_region(canvas, &ink); + as_ink.channels[1].surface = s; as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0)); - assert_eq!(photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + // Within f32 rounding: the inverted copy is re-quantised to the channel depth. + let (shown, inked) = (photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + assert_eq!(shown.len(), inked.len()); + for (a, b) in shown.iter().zip(&inked) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} vs {b:?} ({depth}-bit)"); + } // Saved back unchanged: same DisplayInfo, same planes, same channels on reopening. let (back, d2) = roundtrip(&d); @@ -62,6 +73,78 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { } } +/// Stored samples of one big-endian plane at `depth` (8 or 16), from 0..=1 tone values. +fn plane(vals: &[f32], depth: u16) -> Vec { + let max = if depth == 16 { 65535.0 } else { 255.0 }; + vals.iter() + .flat_map(|v| { + let s = (v * max).round() as u16; + if depth == 16 { s.to_be_bytes().to_vec() } else { vec![s as u8] } + }) + .collect() +} + +/// A 4×1 Multichannel file laid out like Photoshop writes one (measured with a file Photoshop saved +/// after RGB → Multichannel): a cyan spot plane stored as ink-inverted tone (0 = solid ink) and a +/// kind-0 alpha plane stored as-is (max = selected), selected in the left half. +fn ink_and_alpha_file(depth: u16) -> PsdFile { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, depth, Compression::Rle, 4, 1); + let mut planes = plane(&[0.0, 1.0, 0.0, 1.0], depth); + planes.extend(plane(&[1.0, 1.0, 0.0, 0.0], depth)); + f.image_data = photocraft_psd::ImageData::encode(Compression::Rle, &planes, &f.header).unwrap(); + let mut info = 1u32.to_be_bytes().to_vec(); + display_record(&mut info, [0, 65535, 65535], 0, 2); + display_record(&mut info, [65535, 0, 0], 50, 0); + f.resources.push(ImageResource::new(1077, info)); + f +} + +fn values(ch: &photocraft_doc::AlphaChannel) -> Vec { + ch.surface.read_region(photocraft_geom::Rect::new(0, 0, 4, 1)).chunks_exact(ch.surface.channels()).map(|v| v[0]).collect() +} + +#[test] +fn alpha_type_channels_are_stored_as_is_not_as_ink() { + for depth in [8, 16] { + let f = ink_and_alpha_file(depth); + let (d, _) = psd_to_document(&f); + // A white (selected) alpha plane imports as 1.0, like an alpha channel of any other mode; + // the spot plane is still read as ink density (stored 0 = solid ink). + assert_eq!(d.channels[1].spot, None); + assert_eq!(values(&d.channels[1]), [1.0, 1.0, 0.0, 0.0], "alpha channel ({depth}-bit)"); + assert_eq!(values(&d.channels[0]), [1.0, 0.0, 1.0, 0.0], "spot channel ({depth}-bit)"); + + // The composite is what Photoshop shows: the alpha prints as black ink where unselected. + let px = photocraft_compose::flatten(&d).px; + assert_eq!(px.iter().map(|p| [p[0], p[1], p[2]]).collect::>(), [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + + // Saved back byte for byte. + let (back, d2) = roundtrip(&d); + assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap(), "{depth}-bit"); + assert_eq!(values(&d2.channels[1]), [1.0, 1.0, 0.0, 0.0]); + } +} + +#[test] +fn a_full_alpha_channel_exports_as_a_full_plane() { + use photocraft_color::{Color, ColorMode as Mode, PixelFormat, SampleType}; + for (sample, depth) in [(SampleType::U8, 8u16), (SampleType::U16, 16)] { + let mut doc = photocraft_doc::Document::new("m", photocraft_doc::Size::new(4, 1), Mode::Multichannel, sample); + let fmt = PixelFormat::new(Mode::Grayscale, sample, false); + let surface = |v: f32| { + let mut s = photocraft_raster::Surface::new(fmt); + s.write_region(photocraft_geom::Rect::new(0, 0, 4, 1), &[v; 4]); + s + }; + doc.channels.push(photocraft_doc::AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..photocraft_doc::AlphaChannel::new("Black", surface(1.0)) }); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", surface(1.0))); + let (back, _) = roundtrip(&doc); + let mut want = plane(&[0.0; 4], depth); + want.extend(plane(&[1.0; 4], depth)); + assert_eq!(back.decode_merged().unwrap(), want, "solid ink is stored 0, a full alpha max ({depth}-bit)"); + } +} + #[test] fn resources_and_global_blocks_survive_a_save() { let mut f = file(8); diff --git a/crates/io/tests/orientation.rs b/crates/io/tests/it/orientation.rs similarity index 100% rename from crates/io/tests/orientation.rs rename to crates/io/tests/it/orientation.rs diff --git a/crates/io/tests/pattern_fill_psd.rs b/crates/io/tests/it/pattern_fill_psd.rs similarity index 100% rename from crates/io/tests/pattern_fill_psd.rs rename to crates/io/tests/it/pattern_fill_psd.rs diff --git a/crates/io/tests/pcraft_manifest_name.rs b/crates/io/tests/it/pcraft_manifest_name.rs similarity index 98% rename from crates/io/tests/pcraft_manifest_name.rs rename to crates/io/tests/it/pcraft_manifest_name.rs index 285fa02203..fffb88fa8b 100644 --- a/crates/io/tests/pcraft_manifest_name.rs +++ b/crates/io/tests/it/pcraft_manifest_name.rs @@ -1,7 +1,7 @@ //! A `.pcraft` bundle's manifest reports the file it is in (#3312): writing a document to a new //! name must not leave the previous one inside, on every path that produces `.pcraft` bytes. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/pdn_corpus.rs b/crates/io/tests/it/pdn_corpus.rs similarity index 98% rename from crates/io/tests/pdn_corpus.rs rename to crates/io/tests/it/pdn_corpus.rs index 1e67b3d22b..c85b87f3ee 100644 --- a/crates/io/tests/pdn_corpus.rs +++ b/crates/io/tests/it/pdn_corpus.rs @@ -1,5 +1,5 @@ //! Local PDN oracles are not bundled. Set PDN_TEST_DIR to a directory containing PDNs and their -//! exported PNGs, then run `cargo test -p photocraft-io --test pdn_corpus -- --ignored --nocapture`. +//! exported PNGs, then run `cargo test -p photocraft-io --test it -- pdn_corpus:: --ignored --nocapture`. use std::path::{Path, PathBuf}; diff --git a/crates/io/tests/png_text_metadata.rs b/crates/io/tests/it/png_text_metadata.rs similarity index 100% rename from crates/io/tests/png_text_metadata.rs rename to crates/io/tests/it/png_text_metadata.rs diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/it/psd16_merged_transparency.rs similarity index 99% rename from crates/io/tests/psd16_merged_transparency.rs rename to crates/io/tests/it/psd16_merged_transparency.rs index 02d6c0e6b3..82b054e873 100644 --- a/crates/io/tests/psd16_merged_transparency.rs +++ b/crates/io/tests/it/psd16_merged_transparency.rs @@ -5,7 +5,7 @@ //! Photoshop's 16-bit PSB in the report has four channels, a positive layer count in `Lr16`, no //! `Mt16` block, and one alpha channel name, "Transparency". Re-saving it wrote five channels. -mod common; +use crate::common; use common::raster; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd32_export.rs b/crates/io/tests/it/psd32_export.rs similarity index 99% rename from crates/io/tests/psd32_export.rs rename to crates/io/tests/it/psd32_export.rs index b4707fadb0..f9a297a7f6 100644 --- a/crates/io/tests/psd32_export.rs +++ b/crates/io/tests/it/psd32_export.rs @@ -6,7 +6,7 @@ //! resources, layer info in an `Lr32` global block with an empty classic layer info, channel //! compression codes valid for 32-bit data, and a raw big-endian float merged image. -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd_background.rs b/crates/io/tests/it/psd_background.rs similarity index 100% rename from crates/io/tests/psd_background.rs rename to crates/io/tests/it/psd_background.rs diff --git a/crates/io/tests/it/psd_block_alignment.rs b/crates/io/tests/it/psd_block_alignment.rs new file mode 100644 index 0000000000..279a352cf7 --- /dev/null +++ b/crates/io/tests/it/psd_block_alignment.rs @@ -0,0 +1,96 @@ +//! Every layer-level tagged block of an exported PSD/PSB is a multiple of 4 bytes long. +//! +//! Photoshop pads layer-level blocks to 4 inside the length field; all of its own files do (567 +//! fixtures checked), and a regenerated block that stopped at an even but unaligned size left the +//! layer record's extra data unaligned: a Pattern Fill's `PtFl` came out 2 bytes short of the +//! boundary for half of all pattern names. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, Pattern}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::*; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +fn pattern(name: &str) -> Pattern { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[0.8, 0.8, 0.8, 1.0]); + s.fill_rect(Rect::new(0, 0, 4, 4), &[0.2, 0.4, 0.9, 1.0]); + Pattern { id: "059289fb-0000-4000-8000-000000000001".into(), name: name.into(), width: 8, height: 8, surface: s } +} + +/// One Pattern Fill layer per name; the name's length moves the descriptor's size by two bytes +/// per character, so consecutive lengths cover every residue modulo 4. +fn doc(depth: SampleType, names: &[&str]) -> Document { + let mut d = Document::new("p", Size::new(40, 24), ColorMode::Rgb, depth); + for name in names { + let p = pattern(name); + let fill = Fill::Pattern { name: (*name).into(), scale: 1.0, id: p.id.clone(), angle: 0.0, link: true, phase: (0.0, 0.0) }; + d.patterns.push(p); + d.layers.push(Layer::new(format!("Fill {name}"), LayerContent::Fill(fill))); + } + d +} + +#[test] +fn layer_blocks_are_four_byte_aligned() { + let names = ["A", "Ab", "Abc", "Abcd", "Dots", "Checker", "Wood grain"]; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for path in ["x.psd", "x.psb"] { + let bytes = export(&doc(depth, &names), path, &ExportOptions::default()).expect("export").bytes; + let file = PsdFile::from_bytes(&bytes).expect("reparse"); + let mut fills = 0; + for layer in file.layers() { + for b in &layer.blocks { + let key = String::from_utf8_lossy(&b.key).into_owned(); + assert_eq!(b.data.len() % 4, 0, "{depth:?} {path}: layer {:?} block {key} is {} bytes", layer.name(), b.data.len()); + } + fills += usize::from(layer.block(b"PtFl").is_some()); + } + assert_eq!(fills, names.len(), "{depth:?} {path}: every pattern fill keeps its PtFl block"); + } + } +} + +#[test] +fn generated_gradient_map_blocks_are_four_byte_aligned() { + let cases = [(vec![(0.0, [0.04, 0.1, 0.18]), (1.0, [1.0; 3])], 128), (vec![(0.0, [0.04, 0.1, 0.18]), (0.5, [0.4, 0.2, 0.1]), (1.0, [1.0; 3])], 148)]; + for (stops, expected_length) in cases { + let mut doc = Document::new("gradient", Size::new(16, 16), ColorMode::Rgb, SampleType::U8); + doc.layers.push(Layer::new("Gradient Map", LayerContent::Adjustment(Adjustment::GradientMap { stops, reverse: false, dither: false }))); + let bytes = export(&doc, "gradient.psd", &ExportOptions::default()).expect("export").bytes; + let file = PsdFile::from_bytes(&bytes).expect("reparse"); + let block = file.layers().iter().find_map(|layer| layer.block(b"grdm")).expect("Gradient Map block"); + assert_eq!(block.data.len(), expected_length, "grdm declared length must include padding"); + } +} + +/// A file saved by an older build carries the 2-mod-4 `grdm` Photoshop refuses; saving it again, +/// with the stops unchanged or only Reverse toggled, must write an aligned block (#8070). +#[test] +fn legacy_unaligned_gradient_map_is_repaired_on_resave() { + let stops = vec![(0.0, [0.04, 0.1, 0.18]), (0.5, [0.4, 0.2, 0.1]), (1.0, [1.0; 3])]; + for mode in [ColorMode::Rgb, ColorMode::Cmyk] { + let mut doc = Document::new("gradient", Size::new(16, 16), mode, SampleType::U8); + doc.layers.push(Layer::new("Gradient Map", LayerContent::Adjustment(Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }))); + let mut file = PsdFile::from_bytes(&export(&doc, "gradient.psd", &ExportOptions::default()).expect("export").bytes).expect("reparse"); + let legacy = file.layers_mut().iter_mut().find_map(|layer| layer.block_mut(b"grdm")).expect("Gradient Map block"); + let unpadded = legacy.data.len() - 2; + legacy.data.truncate(unpadded); + let legacy = file.to_bytes().expect("legacy file"); + for reverse in [false, true] { + let mut reopened = import("gradient.psd", &legacy).expect("import").document; + let Some(LayerContent::Adjustment(Adjustment::GradientMap { reverse: r, .. })) = reopened.layers.iter_mut().map(|l| &mut l.content).last() else { + panic!("no Gradient Map layer") + }; + *r = reverse; + let expected = reopened.layers.last().map(|l| l.content.clone()); + let bytes = export(&reopened, "gradient.psd", &ExportOptions::default()).expect("re-save").bytes; + let file = PsdFile::from_bytes(&bytes).expect("reparse"); + let block = file.layers().iter().find_map(|layer| layer.block(b"grdm")).expect("Gradient Map block"); + assert_eq!(block.data.len() % 4, 0, "{mode:?} reverse {reverse}: re-saved grdm is {} bytes", block.data.len()); + let again = import("gradient.psd", &bytes).expect("reopen").document; + assert_eq!(again.layers.last().map(|l| l.content.clone()), expected, "{mode:?} reverse {reverse}: Gradient Map changed on re-save"); + } + } +} diff --git a/crates/io/tests/psd_block_layout.rs b/crates/io/tests/it/psd_block_layout.rs similarity index 99% rename from crates/io/tests/psd_block_layout.rs rename to crates/io/tests/it/psd_block_layout.rs index bb858633b5..d081bfbc43 100644 --- a/crates/io/tests/psd_block_layout.rs +++ b/crates/io/tests/it/psd_block_layout.rs @@ -3,7 +3,7 @@ //! to even, a layer info body not padded to 4 and, in PSB, `cinf`/`lnkE` read and written //! with 4-byte lengths; psd-tools rejected or misaligned all of them. -mod common; +use crate::common; use photocraft_io::*; use photocraft_psd::descriptor::{Descriptor, VersionedDescriptor}; diff --git a/crates/io/tests/psd_damaged_merged.rs b/crates/io/tests/it/psd_damaged_merged.rs similarity index 100% rename from crates/io/tests/psd_damaged_merged.rs rename to crates/io/tests/it/psd_damaged_merged.rs diff --git a/crates/io/tests/psd_empty_channels.rs b/crates/io/tests/it/psd_empty_channels.rs similarity index 99% rename from crates/io/tests/psd_empty_channels.rs rename to crates/io/tests/it/psd_empty_channels.rs index 285f03cfcd..610bb145cc 100644 --- a/crates/io/tests/psd_empty_channels.rs +++ b/crates/io/tests/it/psd_empty_channels.rs @@ -1,4 +1,4 @@ -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; use photocraft_io::{ExportOptions, export}; diff --git a/crates/io/tests/psd_font_identity.rs b/crates/io/tests/it/psd_font_identity.rs similarity index 100% rename from crates/io/tests/psd_font_identity.rs rename to crates/io/tests/it/psd_font_identity.rs diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/it/psd_kerning.rs similarity index 100% rename from crates/io/tests/psd_kerning.rs rename to crates/io/tests/it/psd_kerning.rs diff --git a/crates/io/tests/psd_layer_block_length.rs b/crates/io/tests/it/psd_layer_block_length.rs similarity index 99% rename from crates/io/tests/psd_layer_block_length.rs rename to crates/io/tests/it/psd_layer_block_length.rs index acb4a6b15c..71f8e2552d 100644 --- a/crates/io/tests/psd_layer_block_length.rs +++ b/crates/io/tests/it/psd_layer_block_length.rs @@ -4,7 +4,7 @@ //! Photoshop writes these blocks this way. Readers that check the block length exactly (Unity's //! PSD Importer, "Incorrect length for InfoLayers") reject a block whose length counts the padding. -mod common; +use crate::common; use common::Features; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/it/psd_maximize_compatibility.rs similarity index 100% rename from crates/io/tests/psd_maximize_compatibility.rs rename to crates/io/tests/it/psd_maximize_compatibility.rs diff --git a/crates/io/tests/psd_noise_fill_roundtrip.rs b/crates/io/tests/it/psd_noise_fill_roundtrip.rs similarity index 100% rename from crates/io/tests/psd_noise_fill_roundtrip.rs rename to crates/io/tests/it/psd_noise_fill_roundtrip.rs diff --git a/crates/io/tests/psd_roundtrip_bytes.rs b/crates/io/tests/it/psd_roundtrip_bytes.rs similarity index 100% rename from crates/io/tests/psd_roundtrip_bytes.rs rename to crates/io/tests/it/psd_roundtrip_bytes.rs diff --git a/crates/io/tests/psd_structure.rs b/crates/io/tests/it/psd_structure.rs similarity index 81% rename from crates/io/tests/psd_structure.rs rename to crates/io/tests/it/psd_structure.rs index 650aab3d93..410a9f60e9 100644 --- a/crates/io/tests/psd_structure.rs +++ b/crates/io/tests/it/psd_structure.rs @@ -196,6 +196,113 @@ fn palette_and_duotone_modes_are_written_in_their_own_mode() { } } +#[test] +fn a_duotone_made_in_photocraft_is_written_with_its_inks() { + use photocraft_doc::{Duotone, DuotoneInk}; + let mut d = photocraft_doc::Document::with_background( + "d", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Grayscale, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(0.5, 0.5, 0.5, 1.0), + ); + d.mode = photocraft_color::ColorMode::Duotone; + d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None }); + let out = export(&d, "x.psd", &ExportOptions::default()).unwrap(); + assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{:?}", out.warnings); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!((f.header.color_mode, f.color_mode_data.len()), (ColorMode::Duotone, 524)); + let (back, w) = psd_to_document(&f); + assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}"); + let inks = &back.duotone.as_ref().unwrap().inks; + assert_eq!(inks.iter().map(|i| i.name.as_str()).collect::>(), ["Black", "Orange"]); + assert!((inks[1].color[1] - 0.5).abs() < 1e-4, "{:?}", inks[1].color); + // Re-exported unedited, the block is the one read. + let again = PsdFile::from_bytes(&export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(again.color_mode_data, f.color_mode_data); +} + +fn duotone_doc(sample: photocraft_color::SampleType) -> photocraft_doc::Document { + use photocraft_doc::{Duotone, DuotoneInk}; + let mut d = photocraft_doc::Document::with_background( + "d", + photocraft_doc::Size::new(4, 2), + photocraft_color::ColorMode::Grayscale, + sample, + photocraft_color::Color::rgba(0.5, 0.5, 0.5, 1.0), + ); + d.mode = photocraft_color::ColorMode::Duotone; + d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None }); + d +} + +#[test] +fn a_16_bit_duotone_is_written_as_grayscale() { + // Photoshop has only 8-bit Duotone and refuses to open a 16-bit Duotone PSD. + let out = export(&duotone_doc(photocraft_color::SampleType::U16), "x.psd", &ExportOptions::default()).unwrap(); + let f = PsdFile::from_bytes(&out.bytes).unwrap(); + assert_eq!((f.header.color_mode, f.header.depth), (ColorMode::Grayscale, 16)); + assert!(out.warnings.iter().any(|w| w == "Duotone document written as Grayscale"), "{:?}", out.warnings); +} + +#[test] +fn a_layered_duotone_keeps_its_image_without_maximize_compatibility() { + // A Duotone opens flattened from its merged image (its layer records are not read), so that + // image is written in full even when Maximize Compatibility is off, never the placeholder. + let mut d = duotone_doc(photocraft_color::SampleType::U8); + let mut top = photocraft_doc::Layer::new("Dark", LayerContent::Raster(photocraft_raster::Surface::new(d.pixel_format()))); + if let LayerContent::Raster(s) = &mut top.content { + s.fill_rect(photocraft_geom::Rect::new(0, 0, 2, 2), &photocraft_raster::from_rgba(&d.pixel_format(), [0.0, 0.0, 0.0, 1.0])); + } + d.layers.push(top); + let opts = ExportOptions { psd_maximize_compatibility: false, ..Default::default() }; + let f = PsdFile::from_bytes(&export(&d, "x.psd", &opts).unwrap().bytes).unwrap(); + assert_eq!(f.header.color_mode, ColorMode::Duotone); + assert!(!f.layers().is_empty(), "the layers are still written"); + // The grey plate: the left half black (the top layer), the right half the 50% grey background. + let plate: Vec = f.decode_merged().unwrap(); + assert_eq!(plate.len(), 8); + assert!(plate[..2].iter().chain(&plate[4..6]).all(|&v| v == 0), "{plate:?}"); + assert!(plate[2..4].iter().chain(&plate[6..]).all(|&v| (127..=128).contains(&v)), "{plate:?}"); + let (back, _) = psd_to_document(&f); + let maxed = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + assert_eq!(f.decode_merged().unwrap(), maxed.decode_merged().unwrap()); + assert_eq!(back.duotone.as_ref().map(|x| x.inks.len()), Some(2)); +} + +#[test] +fn a_colour_book_ink_shows_its_lab_equivalent_or_the_grey_plate() { + let out = export(&duotone_doc(photocraft_color::SampleType::U8), "x.psd", &ExportOptions::default()).unwrap(); + let mut f = PsdFile::from_bytes(&out.bytes).unwrap(); + // The second ink becomes PANTONE 327 CVC, stored by its book key as Photoshop does. + f.color_mode_data[14..24].copy_from_slice(&[0, 3, 0x20, 0x20, 0x33, 0x32, 0x37, 0x43, 0, 0]); + // Without resource 1066 its colour is unknown: the grey plate, not a black ink. + let (d, w) = psd_to_document(&f); + let inks = &d.duotone.as_ref().unwrap().inks; + assert_eq!((inks.len(), inks[0].color), (1, [0.0; 3]), "{inks:?}"); + assert!(w.iter().any(|w| w.contains("shown as grayscale")), "{w:?}"); + // With Photoshop's Lab equivalents (black, then the PANTONE teal) it shows in colour. + let lab = vec![0, 1, 0, 2, 0, 7, 0x03, 0xB9, 0x01, 0xF2, 0x01, 0xA9, 0, 0, 0, 7, 0x11, 0xC5, 0xE6, 0x38, 0xFE, 0x0C, 0, 0]; + f.resources.push(photocraft_psd::ImageResource::new(1066, lab)); + let (d, w) = psd_to_document(&f); + assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}"); + let duo = d.duotone.as_ref().unwrap(); + assert_eq!(duo.inks.len(), 2); + let [r, g, b] = duo.inks[1].color; + assert!(r < 0.1 && g > 0.4 && b > 0.35, "{:?}", duo.inks[1].color); + assert_eq!(duo.psd_raw.as_ref(), Some(&f.color_mode_data), "the block is kept raw"); +} + +#[test] +fn a_layered_duotone_tiff_says_its_inks_are_dropped() { + // TIFF has no Duotone: a layered one is written in its grey plate, and says so. + let mut d = duotone_doc(photocraft_color::SampleType::U8); + let adj = d.duotone.as_ref().unwrap().display_adjustment(); + d.layers.push(photocraft_doc::Layer::new("Map", LayerContent::Adjustment(adj))); + let out = export(&d, "x.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + assert!(out.warnings.iter().any(|w| w == "Duotone document written as Grayscale"), "{:?}", out.warnings); +} + #[test] fn indexed_psd_keeps_the_table_length_and_transparent_entry() { use photocraft_doc::ColorTable; @@ -287,6 +394,33 @@ fn indexed_document_whose_only_entry_is_transparent_is_not_written_as_indexed() assert_eq!(&f.composite_rgba8().unwrap().data[..4], &[255, 0, 0, 255]); } +#[test] +fn indexed_save_warns_whenever_flattening_loses_a_layer() { + // One plain visible pixel layer is the merged image: no warning. A single hidden, masked, + // half-opaque or type layer, or a second layer, is lost in the flat file, so the save says so. + let flat = |d: &photocraft_doc::Document| { + let out = export(d, "x.psd", &ExportOptions::default()).unwrap(); + assert_eq!(PsdFile::from_bytes(&out.bytes).unwrap().header.color_mode, ColorMode::Indexed); + out.warnings.iter().any(|w| w.contains("written flattened")) + }; + let plain = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255], [0, 0, 0]], None); + assert!(!flat(&plain), "a single plain layer flattens losslessly"); + let changes: [fn(&mut photocraft_doc::Layer); 4] = [ + |l| l.visible = false, + |l| l.opacity = 0.5, + |l| l.mask = Some(photocraft_doc::LayerMask::hide_all()), + |l| l.content = LayerContent::Text(photocraft_doc::TextLayer::default()), + ]; + for (i, change) in changes.iter().enumerate() { + let mut d = plain.clone(); + change(&mut d.layers[0]); + assert!(flat(&d), "change {i} is lost when flattened, so the save warns"); + } + let mut two = plain.clone(); + two.layers.push(two.layers[0].clone()); + assert!(flat(&two)); +} + #[test] fn indexed_and_bitmap_documents_with_channels_keep_them() { // PhotoCraft does not read extra channels of a palette-mode PSD back, so these documents keep @@ -560,9 +694,11 @@ fn text_and_smart_detected() { use photocraft_psd::{LayerRecord, TaggedBlock}; let mut f = testgen::small(Version::Psd, Compression::Raw); for (name, key) in [("t", b"TySh"), ("s", b"PlLd"), ("sh", b"vscg")] { + // Type identification needs readable type data, not an eight-byte malformed stub. + let data = if key == b"TySh" { photocraft_text::psd::build_tysh(&photocraft_doc::TextLayer::default(), 72.0, None) } else { vec![0; 8] }; f.layers_mut().push(LayerRecord { name: name.as_bytes().to_vec(), - blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, vec![0; 8])], + blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, data)], ..Default::default() }); } diff --git a/crates/io/tests/psd_style_sheets.rs b/crates/io/tests/it/psd_style_sheets.rs similarity index 100% rename from crates/io/tests/psd_style_sheets.rs rename to crates/io/tests/it/psd_style_sheets.rs diff --git a/crates/io/tests/psd_thumbnail.rs b/crates/io/tests/it/psd_thumbnail.rs similarity index 100% rename from crates/io/tests/psd_thumbnail.rs rename to crates/io/tests/it/psd_thumbnail.rs diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/it/psd_type_bounds.rs similarity index 95% rename from crates/io/tests/psd_type_bounds.rs rename to crates/io/tests/it/psd_type_bounds.rs index 8fabe8be24..b87d4f4a3a 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/it/psd_type_bounds.rs @@ -10,7 +10,7 @@ //! //! Fonts: the bundled Inter (deterministic), so our render is the "Photoshop" render here. -mod common; +use crate::common; use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::text::TextShape; @@ -367,7 +367,21 @@ fn hostile_type_sizes_without_pixels_import_without_a_huge_render() { { let runs = vec![Run { len: 1, size, leading: None }]; let data = tysh("M", &runs, None, transform, [10.0, 10.0, 20.0, 20.0]); - let mut t = photocraft_text::psd::text_layer_from_tysh(&data, 72.0).unwrap(); + let Some(mut t) = photocraft_text::psd::text_layer_from_tysh(&data, 72.0) else { + // Unsafe affine data is now rejected at the boundary (#4758). Exercise the + // actual file import too: with no saved pixels it must remain empty and safe. + let mut file = photocraft_psd::testgen::small(photocraft_psd::Version::Psd, photocraft_psd::Compression::Raw); + file.layers_mut().push(photocraft_psd::LayerRecord { + name: b"hostile".to_vec(), + blocks: vec![photocraft_psd::TaggedBlock::new(*b"TySh", data)], + ..Default::default() + }); + let back = photocraft_io::import("t.psd", &file.to_bytes().unwrap()).unwrap(); + assert!(back.warnings.iter().any(|w| w.contains("unsafe type geometry"))); + let LayerContent::Raster(pixels) = &back.document.layers.last().unwrap().content else { panic!("unsafe type must stay pixels") }; + assert!(pixels.content_bounds().is_empty()); + continue; + }; if big { // Precondition: the renderer's own guard (256 Mpx) would have drawn this one. let mut engine = photocraft_text::TextEngine::new(); diff --git a/crates/io/tests/psd_type_photoshop_bytes.rs b/crates/io/tests/it/psd_type_photoshop_bytes.rs similarity index 100% rename from crates/io/tests/psd_type_photoshop_bytes.rs rename to crates/io/tests/it/psd_type_photoshop_bytes.rs diff --git a/crates/io/tests/it/psd_unknown_adjustment.rs b/crates/io/tests/it/psd_unknown_adjustment.rs new file mode 100644 index 0000000000..5a7981c5c8 --- /dev/null +++ b/crates/io/tests/it/psd_unknown_adjustment.rs @@ -0,0 +1,106 @@ +//! An adjustment layer whose key PhotoCraft doesn't know (an older or newer adjustment type) +//! opens as an unsupported adjustment with a warning and saves its block back unchanged, instead +//! of opening silently as an empty pixel layer (#6575). Pixel layers that merely carry an unknown +//! block stay pixel layers. + +use photocraft_doc::{Adjustment, Document, LayerContent}; +use photocraft_io::*; +use photocraft_psd::testgen; +use photocraft_psd::{ChannelData, Compression, LayerFlags, LayerRecord, PsdFile, Rect, TaggedBlock, Version}; + +/// A layer record as Photoshop writes an adjustment layer: no pixels, "pixel data irrelevant" +/// set, the adjustment block first, then the usual metadata. +fn adjustment_record(name: &str, blocks: Vec) -> LayerRecord { + let mut flags = LayerFlags::default(); + flags.set_pixel_data_irrelevant(true); + let mut all = blocks; + all.extend([TaggedBlock::unicode_name(name), TaggedBlock::layer_id(77)]); + LayerRecord { name: name.as_bytes().to_vec(), flags, blocks: all, ..Default::default() } +} + +fn open(f: &PsdFile) -> (Document, Vec) { + let bytes = f.to_bytes().expect("write psd"); + let imp = import("x.psd", &bytes).expect("import"); + (imp.document, imp.warnings) +} + +fn save(d: &Document) -> PsdFile { + let r = export(d, "x.psd", &ExportOptions::default()).expect("export"); + PsdFile::from_bytes(&r.bytes).expect("reparse") +} + +fn content<'a>(d: &'a Document, name: &str) -> &'a LayerContent { + &d.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("layer {name}")).content +} + +fn record<'a>(f: &'a PsdFile, name: &str) -> &'a LayerRecord { + f.layers().iter().find(|r| r.name() == name).unwrap_or_else(|| panic!("record {name}")) +} + +#[test] +fn unknown_adjustment_key_opens_as_unsupported_and_saves_verbatim() { + // `hue ` is the Photoshop 4 Hue/Saturation block (in the spec, never mapped); `zNew` stands + // for an adjustment type newer than PhotoCraft. + for key in [*b"hue ", *b"zNew"] { + let data = vec![0, 2, 1, 2, 3, 4, 5, 6]; + let mut f = testgen::small(Version::Psd, Compression::Raw); + f.layers_mut().push(adjustment_record("Mystery", vec![TaggedBlock::new(key, data.clone())])); + let (d, warnings) = open(&f); + let k = String::from_utf8_lossy(&key).into_owned(); + assert!( + matches!(content(&d, "Mystery"), LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if *psd_key == k && *raw == data), + "{k}: {:?}", + content(&d, "Mystery") + ); + let w: Vec<&String> = warnings.iter().filter(|w| w.contains("Mystery")).collect(); + assert_eq!(w.len(), 1, "{k}: {warnings:?}"); + assert!(w[0].contains(&format!("\"{k}\"")) && w[0].contains("no effect"), "{k}: {}", w[0]); + + // Saved back: the block exactly once, byte-identical, still an adjustment record. + let g = save(&d); + let r = record(&g, "Mystery"); + let same: Vec<&TaggedBlock> = r.blocks.iter().filter(|b| b.key == key).collect(); + assert_eq!(same.len(), 1, "{k}: {:?}", r.blocks.iter().map(TaggedBlock::key_str).collect::>()); + assert_eq!(same[0].data, data, "{k}"); + assert!(r.flags.bit4_useful() && r.flags.pixel_data_irrelevant(), "{k}"); + assert!(r.rect.is_empty(), "{k}"); + // And it reopens the same way. + let (d2, _) = open(&g); + assert_eq!(content(&d2, "Mystery"), content(&d, "Mystery"), "{k}"); + } +} + +#[test] +fn pixel_layers_with_unknown_blocks_stay_pixel_layers() { + let extra = || TaggedBlock::new(*b"zNew", vec![9, 8, 7, 6]); + let mut f = testgen::small(Version::Psd, Compression::Raw); + // A normal pixel layer (pixels, flag clear) carrying an unknown block. + f.layers_mut()[0].blocks.push(extra()); + // Flagged but with pixels: never dropped for an adjustment. + let plane = |id: i16| ChannelData { id, compression: Some(Compression::Raw), data: vec![200; 4] }; + let mut painted = adjustment_record("painted", vec![extra()]); + painted.rect = Rect::from_xywh(0, 0, 2, 2); + painted.channels = (-1..3).map(plane).collect(); + f.layers_mut().push(painted); + // No pixels and an unknown block, but not marked "pixel data irrelevant": an empty pixel layer. + let mut unflagged = adjustment_record("unflagged", vec![extra()]); + unflagged.flags = LayerFlags::default(); + f.layers_mut().push(unflagged); + // Two unknown blocks: which one is the adjustment is a guess, so nothing is guessed. + f.layers_mut().push(adjustment_record("ambiguous", vec![extra(), TaggedBlock::new(*b"zOth", vec![1, 1, 1, 1])])); + let (d, warnings) = open(&f); + for name in ["a", "painted", "unflagged", "ambiguous"] { + assert!(matches!(content(&d, name), LayerContent::Raster(_)), "{name}: {:?}", content(&d, name)); + assert!(!warnings.iter().any(|w| w.contains(&format!("\"{name}\""))), "{name}: {warnings:?}"); + } + let LayerContent::Raster(s) = content(&d, "painted") else { unreachable!() }; + assert!(!s.content_bounds().is_empty(), "painted keeps its pixels"); + // The unknown block is still kept verbatim on save, once. + let g = save(&d); + for name in ["a", "painted", "unflagged", "ambiguous"] { + let r = record(&g, name); + let kept: Vec<&TaggedBlock> = r.blocks.iter().filter(|b| &b.key == b"zNew").collect(); + assert_eq!(kept.len(), 1, "{name}"); + assert_eq!(kept[0].data, vec![9, 8, 7, 6], "{name}"); + } +} diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/it/psd_vertical_type.rs similarity index 100% rename from crates/io/tests/psd_vertical_type.rs rename to crates/io/tests/it/psd_vertical_type.rs diff --git a/crates/io/tests/raw.rs b/crates/io/tests/it/raw.rs similarity index 71% rename from crates/io/tests/raw.rs rename to crates/io/tests/it/raw.rs index d9e31a87c1..2927634993 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/it/raw.rs @@ -46,6 +46,118 @@ fn cr2_opens() { assert_eq!(r.document.depth, SampleType::U16); } +/// #6957: a developed raw keeps the raw's EXIF (camera, exposure), so File Info shows its Camera +/// Data. The raw's Orientation is left out: the developed image is already upright. +#[test] +fn a_developed_raw_keeps_its_exif() { + let (w, h) = (48, 16); + let data = mosaic(&scene(w, h), w, [0, 1, 1, 2], 256, 12000); + let spec = + Cr2Spec { width: w, height: h, data, precision: 14, components: 2, slices: vec![24, 24], borders: None, wb_rggb: None, orientation: 6, model_id: None }; + let r = import("IMG_0001.CR2", &spec.build()).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)), "developed, not a preview: {:?}", r.warnings); + let exif = r.document.metadata.exif.as_deref().expect("the raw's EXIF"); + let camera = photocraft_algo::exif::read(exif); + assert_eq!(camera.make.as_deref(), Some("Canon")); + assert_eq!(camera.model.as_deref(), Some("Canon EOS Synthetic")); + assert_eq!(camera.exposure_time, Some(0.01)); + assert_eq!(photocraft_codecs::exif_orientation(exif), 1, "no orientation to apply a second time"); + assert_eq!((r.document.size.width, r.document.size.height), (16, 48)); +} + +/// The tags of the Exif directory of an EXIF block (a little-endian TIFF stream). +fn exif_ifd_tags(b: &[u8]) -> Vec { + assert_eq!(&b[..2], b"II"); + let u16_at = |i: usize| u16::from_le_bytes([b[i], b[i + 1]]); + let u32_at = |i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize; + let entries = |at: usize| (0..usize::from(u16_at(at))).map(move |k| at + 2 + 12 * k); + let ifd0 = u32_at(4); + let exif = entries(ifd0).find(|&e| u16_at(e) == 34665).map(|e| u32_at(e + 8)).expect("an Exif directory"); + entries(exif).map(u16_at).collect() +} + +/// A Panasonic RW2 (its TIFF header has 0x55 for 42) with an Exif directory: the developed +/// document keeps it, without the Exif pixel dimensions (the sensor's, not the image's). +#[test] +fn a_developed_rw2_keeps_its_exif() { + let (w, h, bits) = (20usize, 12usize, 12u32); + let data: Vec = (0..w * h).map(|i| (i * 13 % 4000) as u16 + 200).collect(); + let mut t = TiffBuilder::default(); + let raw = t.blob(photocraft_raw::testgen::rw2_pack(&data, w, bits)); + let exif = t.ifd(vec![(33434, Val::Rational(vec![(1, 60)])), (40962, Val::Long(vec![w as u32])), (40963, Val::Long(vec![h as u32]))]); + let white = ((1u32 << bits) - 1) as u16; + let ifd0 = t.ifd(vec![ + (0x0002, Val::Short(vec![w as u16])), + (0x0003, Val::Short(vec![h as u16])), + (0x0004, Val::Short(vec![2])), + (0x0005, Val::Short(vec![2])), + (0x0006, Val::Short(vec![h as u16 - 2])), + (0x0007, Val::Short(vec![w as u16 - 2])), + (0x0009, Val::Short(vec![1])), + (0x000A, Val::Short(vec![bits as u16])), + (0x000E, Val::Short(vec![white])), + (0x000F, Val::Short(vec![white])), + (0x0010, Val::Short(vec![white])), + (0x002D, Val::Short(vec![5])), + (0x010F, Val::Ascii("Panasonic".into())), + (0x0110, Val::Ascii("DC-Synthetic".into())), + (0x0112, Val::Short(vec![1])), + (0x0118, Val::Blobs(vec![raw])), + (34665, Val::Ifds(vec![exif])), + ]); + t.chain = vec![ifd0]; + let mut bytes = t.build(); + bytes[2..4].copy_from_slice(b"U\0"); + let r = import("P1000001.RW2", &bytes).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)), "developed, not a preview: {:?}", r.warnings); + let exif = r.document.metadata.exif.as_deref().expect("the raw's EXIF"); + let camera = photocraft_algo::exif::read(exif); + assert_eq!((camera.make.as_deref(), camera.model.as_deref()), (Some("Panasonic"), Some("DC-Synthetic"))); + assert!(camera.exposure_time.is_some_and(|t| (t - 1.0 / 60.0).abs() < 1e-9)); + let tags = exif_ifd_tags(exif); + assert!(tags.contains(&33434), "{tags:?}"); + assert!(!tags.contains(&40962) && !tags.contains(&40963), "no Exif pixel dimensions: {tags:?}"); +} + +/// A raw whose Exif directory holds only the pixel dimensions, which a developed image drops: +/// IFD0's camera make and model are kept on their own rather than lost with the empty directory. +#[test] +fn a_developed_raw_keeps_its_camera_when_the_exif_directory_empties() { + let (w, h, bits) = (20usize, 12usize, 12u32); + let data: Vec = (0..w * h).map(|i| (i * 13 % 4000) as u16 + 200).collect(); + let mut t = TiffBuilder::default(); + let raw = t.blob(photocraft_raw::testgen::rw2_pack(&data, w, bits)); + let exif = t.ifd(vec![(40962, Val::Long(vec![w as u32])), (40963, Val::Long(vec![h as u32]))]); + let white = ((1u32 << bits) - 1) as u16; + let ifd0 = t.ifd(vec![ + (0x0002, Val::Short(vec![w as u16])), + (0x0003, Val::Short(vec![h as u16])), + (0x0004, Val::Short(vec![2])), + (0x0005, Val::Short(vec![2])), + (0x0006, Val::Short(vec![h as u16 - 2])), + (0x0007, Val::Short(vec![w as u16 - 2])), + (0x0009, Val::Short(vec![1])), + (0x000A, Val::Short(vec![bits as u16])), + (0x000E, Val::Short(vec![white])), + (0x000F, Val::Short(vec![white])), + (0x0010, Val::Short(vec![white])), + (0x002D, Val::Short(vec![5])), + (0x010F, Val::Ascii("Panasonic".into())), + (0x0110, Val::Ascii("DC-Synthetic".into())), + (0x0112, Val::Short(vec![1])), + (0x0118, Val::Blobs(vec![raw])), + (34665, Val::Ifds(vec![exif])), + ]); + t.chain = vec![ifd0]; + let mut bytes = t.build(); + bytes[2..4].copy_from_slice(b"U\0"); + let r = import("P1000001.RW2", &bytes).unwrap(); + let exif = r.document.metadata.exif.as_deref().expect("the raw's camera, without an Exif directory"); + let camera = photocraft_algo::exif::read(exif); + assert_eq!((camera.make.as_deref(), camera.model.as_deref()), (Some("Panasonic"), Some("DC-Synthetic"))); + assert_eq!(camera.exposure_time, None); +} + /// A NEF-like file with Nikon's (undocumented) compression and a full-size /// baseline JPEG preview in IFD0. fn nef_with_preview() -> Vec { diff --git a/crates/io/tests/resolution_axes.rs b/crates/io/tests/it/resolution_axes.rs similarity index 99% rename from crates/io/tests/resolution_axes.rs rename to crates/io/tests/it/resolution_axes.rs index f7f8cbf006..fb2bd209e2 100644 --- a/crates/io/tests/resolution_axes.rs +++ b/crates/io/tests/it/resolution_axes.rs @@ -1,7 +1,7 @@ //! A file whose horizontal and vertical resolutions differ opens at the horizontal one, and the //! import says the vertical one is not kept (#1018). Equal axes, the common case, open silently. -mod common; +use crate::common; use common::*; use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, Metadata, SampleType as CSample}; diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/it/roundtrip.rs similarity index 99% rename from crates/io/tests/roundtrip.rs rename to crates/io/tests/it/roundtrip.rs index 6502079f20..1031b5504a 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/it/roundtrip.rs @@ -1,6 +1,6 @@ //! Document → PSD → Document round trips. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; @@ -340,12 +340,14 @@ fn effects_roundtrip_typed_and_raw() { size: 3.0, position: StrokePosition::Center, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 1.0, 0.0)), + overprint: false, }), Effect::Stroke(StrokeFx { common: FxCommon::new(photocraft_color::BlendMode::Normal, 0.5), size: 6.0, position: StrokePosition::Outside, paint: FxPaint::Color(photocraft_color::Color::rgb(0.0, 0.0, 1.0)), + overprint: false, }), ]; let back = roundtrip(&d); diff --git a/crates/io/tests/smart_contents_identity.rs b/crates/io/tests/it/smart_contents_identity.rs similarity index 100% rename from crates/io/tests/smart_contents_identity.rs rename to crates/io/tests/it/smart_contents_identity.rs diff --git a/crates/io/tests/smart_corpus.rs b/crates/io/tests/it/smart_corpus.rs similarity index 99% rename from crates/io/tests/smart_corpus.rs rename to crates/io/tests/it/smart_corpus.rs index 494a43071c..f258c01103 100644 --- a/crates/io/tests/smart_corpus.rs +++ b/crates/io/tests/it/smart_corpus.rs @@ -10,7 +10,7 @@ //! ones dropped). #![cfg(feature = "corpus")] -mod common; +use crate::common; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/crates/io/tests/svg.rs b/crates/io/tests/it/svg.rs similarity index 87% rename from crates/io/tests/svg.rs rename to crates/io/tests/it/svg.rs index 4f21bd5dcb..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/it/svg.rs @@ -221,57 +221,6 @@ fn text_becomes_outline_shapes_or_warns_without_a_font() { assert!(dark > 4, "glyph strokes cross the middle row: {dark}"); } -/// #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. -#[test] -fn transformed_text_lands_where_resvg_draws_it() { - let font = "font-size=\"24\" font-family=\"DejaVu Sans, Arial, Helvetica, sans-serif\" fill=\"black\""; - let cases = [ - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - format!("IHI"), - ]; - for c in cases { - let svg = format!("{c}"); - let r = open(&svg); - let d = &r.document; - if d.layers.is_empty() { - // No font at all on this machine: the import says so instead. - assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); - return; - } - assert!(matches!(d.layers[0].content, LayerContent::Group(_)), "text stays outline shapes: {c}"); - let imported = photocraft_compose::flatten(d); - let reference = svg::rasterize(svg.as_bytes(), 1.0).unwrap(); - 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 y in 0..60 { - for x in 0..120 { - let (a, b) = (imported.get(x, y)[3] > 0.5, reference.get(x, y)[3] > 0.5); - 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}"); - } -} - #[test] fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() { let r = open(&format!("")); diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/it/text_corpus.rs similarity index 100% rename from crates/io/tests/text_corpus.rs rename to crates/io/tests/it/text_corpus.rs diff --git a/crates/io/tests/text_styles.rs b/crates/io/tests/it/text_styles.rs similarity index 100% rename from crates/io/tests/text_styles.rs rename to crates/io/tests/it/text_styles.rs diff --git a/crates/io/tests/tiff_corpus.rs b/crates/io/tests/it/tiff_corpus.rs similarity index 93% rename from crates/io/tests/tiff_corpus.rs rename to crates/io/tests/it/tiff_corpus.rs index 143e75bea8..697eab6d32 100644 --- a/crates/io/tests/tiff_corpus.rs +++ b/crates/io/tests/it/tiff_corpus.rs @@ -114,6 +114,14 @@ fn corpus_round_trips_through_layered_tiff() { let expected: Document = { let mut d = oracle.clone(); d.channels.clear(); + // A flat Duotone is written as its inks rendered in RGB (as for any flat format). + if flat + && d.mode == photocraft_color::ColorMode::Duotone + && let Some(duo) = d.duotone.take() + { + d.layers.push(Layer::new("Duotone", photocraft_doc::LayerContent::Adjustment(duo.display_adjustment()))); + d.mode = photocraft_color::ColorMode::Rgb; + } d }; let x = photocraft_compose::flatten(&expected).px; diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/it/tiff_layers.rs similarity index 99% rename from crates/io/tests/tiff_layers.rs rename to crates/io/tests/it/tiff_layers.rs index 56e51bf9f9..382f45bc44 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/it/tiff_layers.rs @@ -2,7 +2,7 @@ //! trips in every mode and depth, equivalence with the PSD path, the flat fall-backs, files //! written by an independent implementation (psdtags, both byte orders), and malformed input. -mod common; +use crate::common; use common::*; use photocraft_color::{ColorMode, SampleType}; diff --git a/crates/io/tests/tiff_pages.rs b/crates/io/tests/it/tiff_pages.rs similarity index 100% rename from crates/io/tests/tiff_pages.rs rename to crates/io/tests/it/tiff_pages.rs diff --git a/crates/io/tests/it/tiff_type_geometry.rs b/crates/io/tests/it/tiff_type_geometry.rs new file mode 100644 index 0000000000..8a04c2dc97 --- /dev/null +++ b/crates/io/tests/it/tiff_type_geometry.rs @@ -0,0 +1,86 @@ +//! Photoshop's mixed-order Intel TIFF type blocks and unsafe/unresolved type fallback (#4758). + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::text::{CharStyle, TextRun}; +use photocraft_doc::{Document, Layer, LayerContent, Size, TextLayer}; +use photocraft_geom::Affine; +use photocraft_io::{ExportOptions, PsdExportOptions, document_to_psd_with, export, import, psd_to_document}; + +fn document() -> Document { + let mut doc = Document::new("Synthetic type", Size::new(640, 400), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 300.0; + let mut t = TextLayer { + text: "Synthetic".into(), + transform: Affine { m: [1.8054202887117468, 0.0, -0.143, 1.8063853899082987, 40.0, 120.0] }, + ..Default::default() + }; + t.runs = vec![TextRun { len: t.text.len(), style: CharStyle { size_pt: 24.0, ..Default::default() } }]; + t.sync_summary(); + t.cache = Some(photocraft_text::TextEngine::default().render(&t, doc.resolution_dpi, doc.pixel_format()).1.surface); + doc.layers.push(Layer::new("Synthetic", LayerContent::Text(t))); + doc +} + +#[test] +fn photoshop_mixed_order_tiff_import_keeps_editable_geometry_and_pixels() { + let doc = document(); + let (psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default()); + let canonical = &psd.layers()[0].block(b"TySh").unwrap().data; + let encoded = export(&doc, "test.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + let mut image = photocraft_codecs::decode(&encoded.bytes).unwrap(); + let blocks = image.meta.photoshop_layers.as_mut().unwrap(); + let at = blocks.windows(8).position(|b| b == b"MIB8hSyT").unwrap() + 12; + // Only the text version/descriptor switch order; warp and bounds remain little-endian. + let (_, used) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&canonical[52..]).unwrap(); + blocks[at + 50..at + 52 + used].copy_from_slice(&canonical[50..52 + used]); + let bytes = photocraft_codecs::encode(&image, photocraft_codecs::Format::Tiff, &Default::default()).unwrap(); + let opened = import("photoshop.tif", &bytes).unwrap(); + assert!(!opened.warnings.iter().any(|w| w.contains("TySh")), "{:?}", opened.warnings); + let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") }; + let LayerContent::Text(after) = &opened.document.layers[0].content else { panic!("imported type") }; + assert_eq!(after.transform, before.transform); + assert_eq!(after.size_pt, 24.0); + assert_eq!(after.text, before.text); + assert_eq!(after.cache.as_ref().unwrap().content_bounds(), before.cache.as_ref().unwrap().content_bounds()); + let rerendered = photocraft_text::TextEngine::default().render(after, opened.document.resolution_dpi, opened.document.pixel_format()).1.surface; + assert_eq!(rerendered.content_bounds(), after.cache.as_ref().unwrap().content_bounds()); +} + +#[test] +fn foreign_or_unsafe_type_keeps_cached_pixels_without_editable_geometry() { + let doc = document(); + for foreign in [true, false] { + let (mut psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default()); + let block = psd.layer_info.as_mut().unwrap().layers[0].blocks.iter_mut().find(|b| b.key == *b"TySh").unwrap(); + if foreign { + // A foreign-order marker must be respected even if the descriptor could parse. + block.signature = *b"MIB8"; + } else { + block.data[2..10].copy_from_slice(&1.315776645269349e62f64.to_be_bytes()); + } + let raw = block.data.clone(); + let (opened, warnings) = psd_to_document(&psd); + let LayerContent::Raster(pixels) = &opened.layers[0].content else { panic!("unsafe type must stay pixels") }; + assert!(!pixels.content_bounds().is_empty()); + assert_eq!(pixels.content_bounds(), doc.layers[0].surface().unwrap().content_bounds()); + assert!(warnings.iter().any(|w| w.contains("unsafe type geometry")), "{warnings:?}"); + assert!(opened.layers[0].psd_blocks.iter().any(|(k, data)| k == b"TySh" && **data == raw)); + } +} + +#[test] +fn canonical_type_the_typed_reader_rejects_stays_type_and_saves_its_block() { + let doc = document(); + let (mut psd, _) = document_to_psd_with(&doc, &PsdExportOptions::default()); + let block = psd.layer_info.as_mut().unwrap().layers[0].blocks.iter_mut().find(|b| b.key == *b"TySh").unwrap(); + // A text descriptor version the typed reader doesn't know; the matrix is ordinary. + block.data[52..56].copy_from_slice(&17u32.to_be_bytes()); + let raw = block.data.clone(); + let (opened, warnings) = psd_to_document(&psd); + assert!(!warnings.iter().any(|w| w.contains("unsafe type geometry")), "{warnings:?}"); + let LayerContent::Text(t) = &opened.layers[0].content else { panic!("canonical type must stay a type layer") }; + let LayerContent::Text(before) = &doc.layers[0].content else { panic!("source type") }; + assert_eq!(t.transform, before.transform); + let (saved, _) = document_to_psd_with(&opened, &PsdExportOptions::default()); + assert_eq!(saved.layers()[0].block(b"TySh").map(|b| &b.data), Some(&raw), "PSD save must keep the type block"); +} diff --git a/crates/io/tests/vector.rs b/crates/io/tests/it/vector.rs similarity index 82% rename from crates/io/tests/vector.rs rename to crates/io/tests/it/vector.rs index 594a1827e8..e32cbed3fa 100644 --- a/crates/io/tests/vector.rs +++ b/crates/io/tests/it/vector.rs @@ -171,6 +171,51 @@ fn custom_stroke_styles_roundtrip_through_psd_vstk() { } } +/// A shape layer's Density and Feather (#5846) live in the PSD mask parameters, as Photoshop +/// writes a shape's vector-mask density and feather; with and without a vector stroke, at 8 and +/// 16 bits, the shape comes back a shape with both and renders its soft fill again. +#[test] +fn shape_density_and_feather_roundtrip_through_mask_parameters() { + let square = Path::new(vec![Subpath::polygon(&[(64.0, 64.0), (192.0, 64.0), (192.0, 192.0), (64.0, 192.0)])]); + for depth in [SampleType::U8, SampleType::U16] { + for stroke in [None, Some(ShapeStroke { width: 2.0, ..ShapeStroke::default() })] { + let mut d = Document::with_background("v", Size::new(256, 256), ColorMode::Rgb, depth, Color::WHITE); + let mut sh = ShapeLayer { + path: square.clone(), + fill: Some(Fill::Solid(Color::BLACK)), + stroke: stroke.clone(), + density: 0.5, + feather: 6.0, + ..ShapeLayer::default() + }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + d.layers.push(Layer::new("Shape", LayerContent::Shape(sh))); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let rec = file.layers().last().unwrap(); + let p = rec.layer_mask().and_then(|m| m.parameters).expect("mask parameters"); + assert_eq!((p.vector_density, p.vector_feather), (Some(128), Some(6.0)), "{depth:?}"); + assert!(rec.channel(-2).is_none(), "no user mask channel"); + let b = import("x.psd", &bytes).unwrap().document; + let LayerContent::Shape(back) = &b.layers[1].content else { panic!("{depth:?} {stroke:?}: {}", b.layers[1].content.kind_name()) }; + assert_eq!((back.density, back.feather), (128.0 / 255.0, 6.0), "{depth:?} {stroke:?}"); + assert_eq!(back.stroke, stroke); + assert!(b.layers[1].mask.is_none() && b.layers[1].vector_mask.is_none()); + let f = photocraft_compose::flatten(&b); + assert!(f.get(128, 128)[0] < 0.01, "inside: black"); + assert!((f.get(4, 4)[0] - 127.0 / 255.0).abs() < 0.01, "{depth:?}: beyond the path at half density: {:?}", f.get(4, 4)); + } + } + // Defaults write no parameters. + let mut d = doc(); + d.layers.push(shape_layer(ShapeLayer { path: square, fill: Some(Fill::Solid(Color::BLACK)), ..ShapeLayer::default() })); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + assert!(file.layers().last().unwrap().layer_mask().and_then(|m| m.parameters).is_none()); + let LayerContent::Shape(back) = &import("x.psd", &bytes).unwrap().document.layers[1].content else { panic!("a shape") }; + assert_eq!((back.density, back.feather), (1.0, 0.0)); +} + #[test] fn vector_mask_roundtrip_and_removal() { let mut d = doc(); diff --git a/crates/io/tests/it/xcf.rs b/crates/io/tests/it/xcf.rs new file mode 100644 index 0000000000..5c2399c3ee --- /dev/null +++ b/crates/io/tests/it/xcf.rs @@ -0,0 +1,347 @@ +//! GIMP XCF files written by GIMP 3.2.6 itself (feature `corpus`; `cargo xtask test-corpus -p io`). +//! They come from `corpus/gimp/` (`cargo xtask corpus --gimp`; pin `PHOTOCRAFT_CORPUS_GIMP_COMMIT` in +//! `xtask/src/corpus_pins.rs`, generator `tools/gimp-xcf/xcf_fixtures.py` in photocraft-corpus); a +//! missing corpus fails. What the reader gets out is checked against the procedural images the +//! generator drew (`pattern`, `alpha`), and our composite of each file against GIMP's own merged +//! rendering saved next to it (`.png`). + +#![cfg(feature = "corpus")] + +use photocraft_color::{BlendMode, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::Rect; +use photocraft_io::{ExportOptions, export, import}; + +fn fixture(name: &str) -> Vec { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/gimp").join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --gimp`", p.display())) +} + +fn open(name: &str) -> photocraft_io::ImportResult { + import(name, &fixture(name)).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +const W: u32 = 40; +const H: u32 = 30; + +fn pattern(x: u32, y: u32, c: u32, maxval: u32) -> u32 { + (x * 31 + y * 17 + c * 101 + 7) * 13 % (maxval + 1) +} + +fn alpha(x: u32, y: u32, w: u32, h: u32, maxval: u32) -> u32 { + ((x + y) * maxval / (w + h - 4).max(1)).min(maxval) +} + +fn raster(layer: &Layer) -> &photocraft_raster::Surface { + match &layer.content { + LayerContent::Raster(s) => s, + _ => panic!("{} is not a pixel layer", layer.name), + } +} + +fn px(layer: &Layer, x: i32, y: i32) -> Vec { + raster(layer).read_region(Rect::new(x, y, x + 1, y + 1)) +} + +fn names(layers: &[Layer]) -> Vec<&str> { + layers.iter().map(|l| l.name.as_str()).collect() +} + +/// Largest premultiplied channel difference between our composite and GIMP's merged PNG, and +/// the share of pixels over `tolerance`. +fn composite_error(doc: &Document, png: &str, tolerance: f32) -> (f32, f32, String) { + let oracle = import(png, &fixture(png)).unwrap().document; + assert_eq!(doc.size, oracle.size, "{png}"); + let ours = photocraft_compose::flatten(doc); + let theirs = photocraft_compose::flatten(&oracle); + let w = doc.size.width as usize; + let mut worst = (0.0f32, String::new()); + let mut over = 0usize; + for (i, (a, b)) in ours.px.iter().zip(&theirs.px).enumerate() { + let e = (0..4).map(|c| if c == 3 { (a[3] - b[3]).abs() } else { (a[c] * a[3] - b[c] * b[3]).abs() }).fold(0.0f32, f32::max); + over += usize::from(e > tolerance); + if e > worst.0 { + let q = |p: &[f32; 4]| p.map(|v| (v * 255.0).round() as i32); + worst = (e, format!("at ({}, {}): ours {:?}, GIMP {:?}", i % w, i / w, q(a), q(b))); + } + } + (worst.0, over as f32 / ours.px.len().max(1) as f32, worst.1) +} + +#[test] +fn layers_8bit_open_with_their_properties_and_pixels() { + let r = open("layers-8bit.xcf"); + let d = &r.document; + assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (W, H, ColorMode::Rgb, SampleType::U8)); + assert_eq!(names(&d.layers), ["Background", "Multiply 60%", "Screen off-canvas", "Hidden"]); + assert!((d.resolution_dpi - 300.0).abs() < 0.01); + assert_eq!((d.guides.horizontal.as_slice(), d.guides.vertical.as_slice()), (&[10.0][..], &[25.0][..])); + assert!(r.source_read_only); + // The background: opaque, every sample as drawn. + let bg = &d.layers[0]; + assert!(bg.locks.transparency, "an opaque XCF layer opens as a locked-transparency layer"); + for (x, y) in [(0, 0), (39, 29), (17, 11)] { + let p = px(bg, x as i32, y as i32); + for c in 0..3 { + assert!((p[c] * 255.0 - pattern(x, y, c as u32, 255) as f32).abs() < 0.51, "background at {x},{y} channel {c}: {p:?}"); + } + assert_eq!(p[3], 1.0); + } + // Multiply at 60%, offset (5, 4), lock alpha, green tag. + let m = &d.layers[1]; + assert_eq!((m.blend, m.opacity, m.visible, m.locks.transparency, m.label), (BlendMode::Multiply, 0.6, true, true, photocraft_doc::LabelColor::Green)); + let p = px(m, 5, 4); + assert!( + (p[0] * 255.0 - pattern(3, 0, 0, 255) as f32).abs() < 0.51 && p[3] == 0.0, + "top-left of the layer is transparent (alpha gradient starts at 0): {p:?}" + ); + let p = px(m, 24, 19); + assert!((p[0] * 255.0 - pattern(19 + 3, 15, 0, 255) as f32).abs() < 0.51 && (p[3] * 255.0 - alpha(19, 15, 20, 16, 255) as f32).abs() < 0.51, "{p:?}"); + assert_eq!(px(m, 4, 4)[3], 0.0, "outside the layer"); + // Screen, partly off-canvas at (-8, 20): its pixels at x < 0 exist. + let s = &d.layers[2]; + assert_eq!(s.blend, BlendMode::Screen); + assert!((px(s, -8, 20)[0] * 255.0 - pattern(7, 0, 0, 255) as f32).abs() < 0.51); + assert!((px(s, 15, 31)[3] * 255.0 - alpha(23, 11, 24, 12, 255) as f32).abs() < 0.51); + // Hidden. + assert!(!d.layers[3].visible); + // The only note: GIMP composites this file in linear light. + assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings); + assert!(r.warnings[0].contains("linear light"), "{:?}", r.warnings); +} + +/// Per mode: our composite of the `legacy-modes-perceptual.xcf` cell against GIMP's. Modes with +/// a Photoshop equivalent must match; the approximated ones are reported. +#[test] +fn each_mode_against_gimp_in_gamma_space() { + let d = open("legacy-modes-perceptual.xcf").document; + let oracle = import("legacy-modes-perceptual.png", &fixture("legacy-modes-perceptual.png")).unwrap().document; + let ours = photocraft_compose::flatten(&d); + let theirs = photocraft_compose::flatten(&oracle); + let w = d.size.width as usize; + // Where the layer pixel is fully opaque the formulas must agree. Where it is translucent, + // GIMP applies the pixel's alpha before clamping the result of a mode that can leave 0..1 + // (Addition, Subtract, Divide, Dodge, Burn, Linear light) and Photoshop clamps first, so + // those pixels are only reported. + let exact = ["Addition", "Subtract", "Divide", "Dodge", "Burn", "Hard light", "Exclusion", "Linear light"]; + let mut report = Vec::new(); + let mut failed = false; + for (i, layer) in d.layers.iter().skip(1).enumerate() { + let (x0, y0) = ((i % 5) * 8, (i / 5) * 10); + let (mut worst_opaque, mut worst_all) = (0.0f32, 0.0f32); + for y in y0..y0 + 8 { + for x in x0..x0 + 8 { + let (a, b) = (ours.px[y * w + x], theirs.px[y * w + x]); + let e = (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); + worst_all = worst_all.max(e); + let opaque = alpha((x - x0) as u32, (y - y0) as u32, 8, 8, 255) == 255; + if opaque { + worst_opaque = worst_opaque.max(e); + } + } + } + let must = exact.contains(&layer.name.as_str()); + let ok = !must || worst_opaque <= 2.5 / 255.0; + failed |= !ok; + report.push(format!( + "{} {:<14} {:?}: opaque pixels worst {:.1}/255, all {:.1}/255", + if ok { "ok " } else { "FAIL" }, + layer.name, + layer.blend, + worst_opaque * 255.0, + worst_all * 255.0 + )); + } + println!("{}", report.join("\n")); + assert!(!failed, "\n{}", report.join("\n")); +} + +#[test] +fn groups_nest_and_pass_through() { + let r = open("groups.xcf"); + let d = &r.document; + assert_eq!(names(&d.layers), ["Background", "Group", "Top"]); + let g = &d.layers[1]; + assert!(g.is_group()); + assert!((g.opacity - 0.7).abs() < 1e-6); + assert_eq!(g.blend, BlendMode::Normal, "a GIMP group in Normal mode composites as a unit"); + let children = g.children().unwrap(); + assert_eq!(names(children), ["Child Multiply", "Nested pass-through"]); + assert_eq!(children[0].blend, BlendMode::Multiply); + let nested = &children[1]; + assert!(nested.is_group()); + assert_eq!(nested.blend, BlendMode::PassThrough); + assert_eq!(names(nested.children().unwrap()), ["Nested child Screen"]); + assert_eq!(nested.children().unwrap()[0].blend, BlendMode::Screen); + assert!((px(&nested.children().unwrap()[0], 18, 10)[0] * 255.0 - pattern(5, 0, 0, 255) as f32).abs() < 0.51, "nested child at its offset"); +} + +#[test] +fn masks_open_applied_or_disabled() { + let d = open("mask.xcf").document; + assert_eq!(names(&d.layers), ["Background", "Masked", "Mask disabled"]); + let masked = d.layers[1].mask.as_ref().expect("mask"); + assert!(masked.enabled); + // A left-to-right gradient across 30 px at the layer's offset (5, 5). The script drew it as + // gamma-encoded values and GIMP stores mask coverage linearly, so the midpoint is where + // sRGB 15/29 lands in linear light. + let v = |x: i32| masked.surface.read_region(Rect::new(x, 10, x + 1, 11))[0]; + let linear = |s: f32| if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }; + assert!(v(5) < 0.01 && (v(5 + 29) - 1.0).abs() < 0.01 && (v(5 + 15) - linear(15.0 / 29.0)).abs() < 0.01, "{} {} {}", v(5), v(34), v(20)); + let disabled = d.layers[2].mask.as_ref().expect("mask"); + assert!(!disabled.enabled); + assert!(disabled.surface.read_region(Rect::new(20, 20, 21, 21))[0] < 0.01, "a black mask, kept but not applied"); +} + +#[test] +fn gray16_keeps_its_depth_and_mode() { + let d = open("gray16.xcf").document; + assert_eq!((d.mode, d.depth), (ColorMode::Grayscale, SampleType::U16)); + assert_eq!(names(&d.layers), ["Background", "Gray alpha"]); + let bg = &d.layers[0]; + assert_eq!(raster(bg).channels(), 2); + for (x, y) in [(0, 0), (39, 29), (13, 7)] { + let p = px(bg, x as i32, y as i32); + assert!((p[0] * 65535.0 - pattern(x, y, 0, 65535) as f32).abs() < 0.6, "gray at {x},{y}: {p:?}"); + } + let top = &d.layers[1]; + let p = px(top, 10 + 19, 8 + 14); + assert!((p[0] * 65535.0 - pattern(19 + 9, 14, 0, 65535) as f32).abs() < 0.6 && (p[1] * 65535.0 - alpha(19, 14, 20, 15, 65535) as f32).abs() < 0.6, "{p:?}"); +} + +#[test] +fn float_linear_opens_as_32_bit_float_with_a_linear_profile() { + let d = open("float-linear.xcf").document; + assert_eq!((d.mode, d.depth), (ColorMode::Rgb, SampleType::F32)); + let icc = d.icc_profile.as_ref().expect("a linear-light profile"); + assert_eq!(**icc, photocraft_cms::Builtin::LinearSrgb.profile().to_bytes().as_ref().clone()); + let bg = &d.layers[0]; + for (x, y) in [(0, 0), (39, 29), (5, 21)] { + let p = px(bg, x as i32, y as i32); + for (c, v) in p.iter().take(3).enumerate() { + assert_eq!(*v, (x * 3 + y * 5 + c as u32 * 7) as f32 / 64.0, "float at {x},{y} channel {c}"); + } + } + assert!(px(bg, 39, 29)[0] > 1.0, "values above 1 are kept"); + let top = &d.layers[1]; + let p = px(top, 12 + 4, 7 + 6); + assert_eq!(p[3], 10.0 / 30.0); +} + +#[test] +fn indexed_opens_as_rgb_with_a_note() { + let r = open("indexed.xcf"); + assert_eq!((r.document.mode, r.document.depth), (ColorMode::Rgb, SampleType::U8)); + assert!(r.warnings.iter().any(|w| w.contains("indexed")), "{:?}", r.warnings); + assert_eq!(names(&r.document.layers), ["Background", "Top"]); +} + +#[test] +fn text_layers_open_as_pixels_with_a_note() { + let r = open("text.xcf"); + assert_eq!(names(&r.document.layers), ["Background", "XCF"]); + assert!(r.warnings.iter().any(|w| w.contains("text layer")), "{:?}", r.warnings); + let text = &r.document.layers[1]; + let s = raster(text); + let region = s.read_region(Rect::new(10, 10, 10 + 60, 10 + 30)); + assert!(region.as_chunks::<4>().0.iter().any(|p| p[3] > 0.5), "the rendered glyphs are there"); +} + +#[test] +fn channels_and_selection_come_through() { + let d = open("channel.xcf").document; + assert_eq!(d.channels.len(), 1); + assert_eq!(d.channels[0].name, "Alpha 1"); + let v = |y: i32| d.channels[0].surface.read_region(Rect::new(3, y, 4, y + 1))[0]; + assert!(v(0) < 0.01 && (v(29) - 1.0).abs() < 0.01, "a top-to-bottom gradient"); + let sel = d.selection.as_ref().expect("the saved selection"); + assert!((sel.read_region(Rect::new(10, 10, 11, 11))[0] - 1.0).abs() < 0.01 && sel.read_region(Rect::new(1, 1, 2, 2))[0] < 0.01); +} + +#[test] +fn modes_map_to_photoshops_and_the_rest_warn() { + let r = open("legacy-modes.xcf"); + let by_name = |n: &str| r.document.layers.iter().find(|l| l.name == n).unwrap_or_else(|| panic!("{n}")); + assert_eq!(by_name("Addition").blend, BlendMode::LinearDodge); + assert_eq!(by_name("Subtract").blend, BlendMode::Subtract); + assert_eq!(by_name("Divide").blend, BlendMode::Divide); + assert_eq!(by_name("Dodge").blend, BlendMode::ColorDodge); + assert_eq!(by_name("Burn").blend, BlendMode::ColorBurn); + assert_eq!(by_name("Hard light").blend, BlendMode::HardLight); + assert_eq!(by_name("Exclusion").blend, BlendMode::Exclusion); + assert_eq!(by_name("Linear light").blend, BlendMode::LinearLight); + assert_eq!(by_name("Grain merge").blend, BlendMode::Normal); + assert_eq!(by_name("HSV Hue").blend, BlendMode::Hue); + assert_eq!(by_name("LCH Color").blend, BlendMode::Color); + let w = r.warnings.join("\n"); + assert!(w.contains("Grain merge") && w.contains("HSV Value") && w.contains("LCH Color"), "{w}"); +} + +#[test] +fn xcf_cannot_be_written_and_says_so() { + let d = open("layers-8bit.xcf").document; + let e = export(&d, "out.xcf", &ExportOptions::default()).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("pcraft"), "{e}"); +} + +#[test] +fn broken_files_are_errors_not_crashes() { + let bytes = fixture("groups.xcf"); + assert!(import("x.xcf", b"gimp xcf v011\0").is_err()); + assert!(import("x.xcf", &bytes[..bytes.len() / 2]).is_err()); + for cut in (0..bytes.len()).step_by(7) { + let _ = import("x.xcf", &bytes[..cut]); + } +} + +/// Our composite against GIMP's merged rendering. The `-perceptual` files have every layer set +/// to blend and composite in gamma-encoded RGB, which is how PhotoCraft composes, so they must +/// match tightly. GIMP's defaults compose in linear light, so those files measure the known gap +/// (the tolerance and the allowed share of pixels over it are the numbers seen, not goals). +/// Per file: tolerance (premultiplied, 0..1), the share of pixels allowed over it, and why. +const COMPOSITES: &[(&str, f32, f32, &str)] = &[ + ("layers-8bit-perceptual.xcf", 2.5 / 255.0, 0.0, "Normal, Multiply and Screen in gamma space"), + ( + "groups-perceptual.xcf", + 2.5 / 255.0, + 0.16, + "groups and pass-through in gamma space; the 15% over are where GIMP's Clip to backdrop hides the Multiply and Screen children above the transparent part of the background", + ), + ("mask-perceptual.xcf", 2.5 / 255.0, 0.0, "masks in gamma space"), + ("text-perceptual.xcf", 2.5 / 255.0, 0.0, "antialiased text in gamma space"), + ( + "legacy-modes-perceptual.xcf", + 2.5 / 255.0, + 0.40, + "modes without an equivalent open as Normal and HSV/LCH modes are approximated; see each_mode_against_gimp_in_gamma_space", + ), + ("gray16.xcf", 2.0 / 255.0, 0.0, "16-bit gray, Normal"), + ("indexed.xcf", 2.0 / 255.0, 0.0, "indexed to RGB"), + ("channel.xcf", 2.0 / 255.0, 0.0, "channels are not rendered"), + ("layers-8bit.xcf", 40.0 / 255.0, 0.0, "GIMP's default: Multiply blended in linear light"), + ("groups.xcf", 140.0 / 255.0, 0.0, "GIMP's default: linear light and Clip to backdrop over a transparent background"), + ("mask.xcf", 60.0 / 255.0, 0.0, "GIMP's default: alpha composited in linear light"), + ("text.xcf", 75.0 / 255.0, 0.0, "GIMP's default: antialiased edges composited in linear light"), +]; + +#[test] +fn composites_match_gimps_merged_rendering() { + let mut report = Vec::new(); + let mut failed = false; + for (name, tolerance, allowed, why) in COMPOSITES { + let d = open(name).document; + let png = name.replace(".xcf", ".png"); + let (worst, over, at) = composite_error(&d, &png, *tolerance); + let ok = over <= *allowed; + failed |= !ok; + report.push(format!( + "{} {name}: worst {:.1}/255, {:.1}% over {:.1}/255 ({why}) {at}", + if ok { "ok " } else { "FAIL" }, + worst * 255.0, + over * 100.0, + tolerance * 255.0 + )); + } + println!("{}", report.join("\n")); + assert!(!failed, "\n{}", report.join("\n")); +} diff --git a/crates/io/tests/xmp_export.rs b/crates/io/tests/it/xmp_export.rs similarity index 100% rename from crates/io/tests/xmp_export.rs rename to crates/io/tests/it/xmp_export.rs diff --git a/crates/io/tests/psd_block_alignment.rs b/crates/io/tests/psd_block_alignment.rs deleted file mode 100644 index 534fdc257b..0000000000 --- a/crates/io/tests/psd_block_alignment.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Every layer-level tagged block of an exported PSD/PSB is a multiple of 4 bytes long. -//! -//! Photoshop pads layer-level blocks to 4 inside the length field; all of its own files do (567 -//! fixtures checked), and a regenerated block that stopped at an even but unaligned size left the -//! layer record's extra data unaligned: a Pattern Fill's `PtFl` came out 2 bytes short of the -//! boundary for half of all pattern names. - -use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Document, Fill, Layer, LayerContent, Pattern}; -use photocraft_geom::{Rect, Size}; -use photocraft_io::*; -use photocraft_psd::PsdFile; -use photocraft_raster::Surface; - -fn pattern(name: &str) -> Pattern { - let mut s = Surface::new(PixelFormat::RGBA8); - s.fill_rect(Rect::new(0, 0, 8, 8), &[0.8, 0.8, 0.8, 1.0]); - s.fill_rect(Rect::new(0, 0, 4, 4), &[0.2, 0.4, 0.9, 1.0]); - Pattern { id: "059289fb-0000-4000-8000-000000000001".into(), name: name.into(), width: 8, height: 8, surface: s } -} - -/// One Pattern Fill layer per name; the name's length moves the descriptor's size by two bytes -/// per character, so consecutive lengths cover every residue modulo 4. -fn doc(depth: SampleType, names: &[&str]) -> Document { - let mut d = Document::new("p", Size::new(40, 24), ColorMode::Rgb, depth); - for name in names { - let p = pattern(name); - let fill = Fill::Pattern { name: (*name).into(), scale: 1.0, id: p.id.clone(), angle: 0.0, link: true, phase: (0.0, 0.0) }; - d.patterns.push(p); - d.layers.push(Layer::new(format!("Fill {name}"), LayerContent::Fill(fill))); - } - d -} - -#[test] -fn layer_blocks_are_four_byte_aligned() { - let names = ["A", "Ab", "Abc", "Abcd", "Dots", "Checker", "Wood grain"]; - for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { - for path in ["x.psd", "x.psb"] { - let bytes = export(&doc(depth, &names), path, &ExportOptions::default()).expect("export").bytes; - let file = PsdFile::from_bytes(&bytes).expect("reparse"); - let mut fills = 0; - for layer in file.layers() { - for b in &layer.blocks { - let key = String::from_utf8_lossy(&b.key).into_owned(); - assert_eq!(b.data.len() % 4, 0, "{depth:?} {path}: layer {:?} block {key} is {} bytes", layer.name(), b.data.len()); - } - fills += usize::from(layer.block(b"PtFl").is_some()); - } - assert_eq!(fills, names.len(), "{depth:?} {path}: every pattern fill keeps its PtFl block"); - } - } -} diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs index 9f1209df45..d0535a86ce 100644 --- a/crates/ipp/src/lib.rs +++ b/crates/ipp/src/lib.rs @@ -12,7 +12,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::io::{Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::path::PathBuf; use std::time::Duration; @@ -111,12 +111,36 @@ impl Endpoint { .find(Endpoint::reachable) } - /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). + /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). An IPv6 + /// server is `[address]` or `[address]:port`; written bare (more than one `:`) it keeps the + /// default port, because its last `:` cannot be told apart from a port separator. Anything else + /// after the bracket (`[::1]:abc`) stays the host, so a mistyped endpoint cannot resolve rather + /// than quietly becoming the default one. pub fn parse(server: &str) -> Endpoint { if server.starts_with('/') { return Endpoint::Unix(PathBuf::from(server)); } - let (host, port) = server.rsplit_once(':').and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); + // `[IPv6]:port` - the brackets tell the address from the port, and are not part of the host. + if let Some(rest) = server.strip_prefix('[') + && let Some((host, tail)) = rest.split_once(']') + { + // Nothing after the bracket, or a real `:port`, is understood. Anything else (`[::1]:abc`, + // `[::1]:70000`, `[::1]junk`) keeps the whole string as the host, so the mistyped endpoint + // stays unresolvable instead of quietly becoming the default one. + let port = if tail.is_empty() { + 631 + } else if let Some(port) = tail.strip_prefix(':').and_then(|p| p.parse().ok()) { + port + } else { + return Endpoint::Tcp(server.to_string(), 631); + }; + return Endpoint::Tcp(host.to_string(), port); + } + // A bare IPv6 literal has more than one ':', so splitting on the last one would cut the + // address in half: it is the host, and the port stays the default. A zone id (`fe80::1%eth0`) + // carries no ':', so there a port can still follow the address. + let host_port = if server.matches(':').count() > 1 && !server.contains('%') { None } else { server.rsplit_once(':') }; + let (host, port) = host_port.and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); Endpoint::Tcp(host, port) } @@ -142,17 +166,23 @@ impl Endpoint { Endpoint::Tcp(host, port) => { let addr = (host.as_str(), *port).to_socket_addrs().map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?.next(); let addr = addr.ok_or_else(|| Error::Unreachable(format!("{host}:{port}: no address")))?; - let s = TcpStream::connect_timeout(&addr, CONNECT).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; - let _ = s.set_read_timeout(Some(REQUEST)); - let _ = s.set_write_timeout(Some(REQUEST)); - Ok(Stream::Tcp(s)) + open_tcp(host, *port, addr, |addr| TcpStream::connect_timeout(&addr, CONNECT)) } } } + /// The endpoint as an HTTP authority: an IPv6 host is bracketed, or its last colon reads as the + /// port separator and the request carries a host CUPS has never heard of. A scoped address keeps + /// its zone id and writes it escaped as "%25": "%" is not valid inside an authority (RFC 3986 + /// section 3.2.2 reserves it for escaping), and the escaped form is what CUPS, curl and the + /// ipp:// URIs CUPS writes use for a zone. RFC 9844 (Aug 2025) obsoleted RFC 6874, which had made + /// that form URI syntax, so this is interop rather than a standard; dropping the zone instead + /// would name a different host on the wire than the socket was opened to. The socket still gets + /// the raw address%zone, which is the form a resolver understands. fn host(&self) -> String { match self { Endpoint::Unix(_) => "localhost".into(), + Endpoint::Tcp(host, port) if host.contains(':') => format!("[{}]:{port}", host.replace('%', "%25")), Endpoint::Tcp(host, port) => format!("{host}:{port}"), } } @@ -162,11 +192,22 @@ impl std::fmt::Display for Endpoint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Endpoint::Unix(p) => write!(f, "{}", p.display()), + Endpoint::Tcp(h, p) if h.contains(':') => write!(f, "[{h}]:{p}"), Endpoint::Tcp(h, p) => write!(f, "{h}:{p}"), } } } +/// Open one address and put the request timeouts on it. The connect call is a parameter so the +/// refusal path can be tested at all: no port is guaranteed to stay free between picking one and +/// using it, and what a refused connect reported is what the caller is supposed to hear. +fn open_tcp(host: &str, port: u16, addr: SocketAddr, open: impl FnOnce(SocketAddr) -> std::io::Result) -> Result { + let s = open(addr).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Tcp(s)) +} + enum Stream { #[cfg(unix)] Unix(std::os::unix::net::UnixStream), @@ -480,8 +521,19 @@ fn dechunk(mut b: &[u8]) -> Result> { } /// Where `/path` lands on the server: `/printers/` or `/classes/`. -fn queue_path(name: &str, is_class: bool) -> String { - format!("/{}/{name}", if is_class { "classes" } else { "printers" }) +fn queue_path(name: &str, is_class: bool) -> Result { + if name.is_empty() || name.bytes().any(|b| b < 0x20 || b == 0x7f) { + return Err(Error::Protocol("printer name contains an invalid control character".into())); + } + let mut encoded = String::with_capacity(name.len()); + for &b in name.as_bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(b)); + } else { + encoded.push_str(&format!("%{b:02X}")); + } + } + Ok(format!("/{}/{encoded}", if is_class { "classes" } else { "printers" })) } /// The printers the server knows (CUPS-Get-Printers), then its classes (CUPS-Get-Classes, which @@ -568,7 +620,7 @@ pub struct Job<'a> { /// Submits `job` (Print-Job) and returns the job id. pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result { let mut r = Request::new(OP_PRINT_JOB, 3); - let path = queue_path(job.printer, job.is_class); + let path = queue_path(job.printer, job.is_class)?; r.attr(TAG_URI, "printer-uri", format!("ipp://{}{path}", endpoint.host()).as_bytes()); r.attr(TAG_NAME, "requesting-user-name", job.user.as_bytes()); r.attr(TAG_NAME, "job-name", job.title.as_bytes()); diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs index ff3ccb6a49..394ff758d1 100644 --- a/crates/ipp/src/tests.rs +++ b/crates/ipp/src/tests.rs @@ -158,7 +158,10 @@ fn a_print_job_carries_the_document_and_returns_its_id() { assert_eq!(op, OP_PRINT_JOB); assert_eq!(doc, pdf); let get = |n: &str| attrs.iter().find(|a| a.name == n).cloned().unwrap_or_else(|| panic!("{n}")); - assert_eq!(get("printer-uri").values[0], format!("ipp://{}/printers/Office", ep.host()).as_bytes()); + let uri = String::from_utf8(get("printer-uri").values[0].clone()).unwrap(); + // Spelled out here rather than echoed back from host(), so the printer-uri the server receives is + // pinned down even when the authority is an IP literal. + assert!(uri.starts_with("ipp://127.0.0.1:") && uri.ends_with("/printers/Office"), "{uri}"); assert_eq!(get("job-name").values[0], b"photo.psd"); assert_eq!(get("requesting-user-name").values[0], b"diego"); assert_eq!(get("document-format").values[0], b"application/pdf"); @@ -186,9 +189,36 @@ fn error_statuses_and_broken_replies_are_errors() { assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); let (ep, _rx) = fake(b"not ipp at all".to_vec(), false, 200); assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); - // Nobody listening. - let e = printers(&Endpoint::Tcp("127.0.0.1".into(), 1)).unwrap_err(); + // Nobody reachable. A port is never guaranteed to be free, let alone to stay free: that is what + // made the old port-1 case fail on a FreeBSD CI VM. A name that can never resolve (RFC 2606) + // reaches the same exit without owning or releasing anything. + let e = printers(&Endpoint::Tcp("printer.invalid".into(), 631)).unwrap_err(); assert!(matches!(e, Error::Unreachable(_)), "{e}"); + assert!(e.to_string().contains("printer.invalid"), "{e} does not name the server it tried"); +} + +#[test] +fn a_refused_connection_is_unreachable_and_keeps_why() { + // The refused-connect path, with the machine no longer deciding what is closed: the connect call + // is a parameter, so this test cannot race for an ephemeral port with anything. + let addr: SocketAddr = "192.0.2.9:631".parse().unwrap(); + let asked_for = std::cell::RefCell::new(None); + let open = |seen: SocketAddr| -> std::io::Result { + *asked_for.borrow_mut() = Some(seen); + Err(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "nobody is listening on this port")) + }; + let e = match open_tcp("printserver", 631, addr, open) { + Ok(_) => panic!("nothing opens {addr}"), + Err(e) => e, + }; + assert_eq!(*asked_for.borrow(), Some(addr), "the connector is handed the resolved address"); + match &e { + Error::Unreachable(m) => { + assert!(m.contains("printserver:631"), "{e} does not name the endpoint"); + assert!(m.contains("nobody is listening on this port"), "{e} loses why the connect failed"); + } + other => panic!("expected Unreachable, got {other}"), + } } #[test] @@ -232,6 +262,54 @@ fn the_unix_socket_endpoint_works_and_detect_honours_cups_server() { let _ = std::fs::remove_dir_all(&dir); } +/// An IPv6 print server is not a `host:port` split: a bare address has more than one ':', and the +/// brackets of `[address]:port` must not end up inside the host, where no resolver can read them. +#[test] +fn an_ipv6_print_server_is_parsed_as_a_host_not_as_host_port() { + assert_eq!(Endpoint::parse("::1"), Endpoint::Tcp("::1".into(), 631)); + assert_eq!(Endpoint::parse("fe80::1"), Endpoint::Tcp("fe80::1".into(), 631)); + assert_eq!(Endpoint::parse("2001:db8::1"), Endpoint::Tcp("2001:db8::1".into(), 631)); + assert_eq!(Endpoint::parse("[::1]"), Endpoint::Tcp("::1".into(), 631)); + assert_eq!(Endpoint::parse("[::1]:631"), Endpoint::Tcp("::1".into(), 631)); + assert_eq!(Endpoint::parse("[fe80::1]:6310"), Endpoint::Tcp("fe80::1".into(), 6310)); + // A zone id carries no ':', so a port can still follow it without brackets. + assert_eq!(Endpoint::parse("fe80::1%eth0:631"), Endpoint::Tcp("fe80::1%eth0".into(), 631)); + // What already worked is unchanged. + assert_eq!(Endpoint::parse("cups.example"), Endpoint::Tcp("cups.example".into(), 631)); + assert_eq!(Endpoint::parse("cups.example:6310"), Endpoint::Tcp("cups.example".into(), 6310)); + assert_eq!(Endpoint::parse("127.0.0.1:6310"), Endpoint::Tcp("127.0.0.1".into(), 6310)); + assert_eq!(Endpoint::parse("/var/run/cups/cups.sock"), Endpoint::Unix("/var/run/cups/cups.sock".into())); + // A mistyped bracketed endpoint is not quietly the default one: the whole string stays the host, + // so nothing can resolve it and detect() reports the endpoint unreachable. + assert_eq!(Endpoint::parse("[::1]:abc"), Endpoint::Tcp("[::1]:abc".into(), 631)); + assert_eq!(Endpoint::parse("[::1]:70000"), Endpoint::Tcp("[::1]:70000".into(), 631)); + assert_eq!(Endpoint::parse("[::1]junk"), Endpoint::Tcp("[::1]junk".into(), 631)); + assert!(!Endpoint::parse("[::1]:abc").reachable()); +} + +/// The authority a request carries (the `Host:` header and the `ipp://` printer-uri) has to bracket an +/// IPv6 host, or its last colon reads as the port separator and CUPS sees a nonsense host. +#[test] +fn an_ipv6_endpoint_writes_its_authority_in_brackets() { + assert_eq!(Endpoint::parse("::1").host(), "[::1]:631"); + assert_eq!(Endpoint::parse("[::1]:631").host(), "[::1]:631"); + assert_eq!(Endpoint::parse("[fe80::1]:6310").host(), "[fe80::1]:6310"); + assert_eq!(Endpoint::parse("cups.example").host(), "cups.example:631"); + assert_eq!(Endpoint::parse("cups.example:6310").host(), "cups.example:6310"); + assert_eq!(Endpoint::parse("127.0.0.1:6310").host(), "127.0.0.1:6310"); + // A scoped address keeps its zone id, but the authority writes it "%25"-escaped: a bare "%" is + // not valid inside an authority, and the escaped form is what CUPS and curl-style ipp:// URIs + // use. The host itself stays raw, because that is what the resolver gets. + assert_eq!(Endpoint::parse("fe80::1%3:631").host(), "[fe80::1%253]:631"); + assert_eq!(Endpoint::parse("[fe80::1%3]:6310").host(), "[fe80::1%253]:6310"); + assert_eq!(Endpoint::parse("fe80::1%eth0:631").host(), "[fe80::1%25eth0]:631"); + assert_eq!(Endpoint::parse("fe80::1%3:631"), Endpoint::Tcp("fe80::1%3".into(), 631)); + // What the user is shown is written the same way, with the zone exactly as it was typed. + assert_eq!(Endpoint::parse("::1").to_string(), "[::1]:631"); + assert_eq!(Endpoint::parse("cups.example:6310").to_string(), "cups.example:6310"); + assert_eq!(Endpoint::parse("fe80::1%3:631").to_string(), "[fe80::1%3]:631"); +} + #[test] fn requests_start_with_the_mandatory_attributes() { let r = Request::new(OP_PRINT_JOB, 9).end(); @@ -393,3 +471,9 @@ fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() { } } } + +#[test] +fn printer_names_are_encoded_as_one_request_target_segment() { + assert_eq!(queue_path("office printer/1", false).unwrap(), "/printers/office%20printer%2F1"); + assert!(queue_path("printer\r\nX-Injected: yes", false).is_err()); +} diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index fdf6dcff9f..498c1deb8e 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -33,6 +33,8 @@ pub struct HistoryState { pub doc: Arc, /// The layers targeted when this state was created. pub layers: LayerTarget, + /// Stable while the state is held: indices shift when older states fall off, ids don't. + pub id: u64, } #[derive(Clone, Debug)] @@ -50,8 +52,29 @@ pub struct History { current_label: String, /// The layers targeted when the current document was created (opened or edited). current_layers: LayerTarget, + /// The states History panel › Clear History took off the list, kept so the next undo puts + /// them back (Clear History is itself undoable). Any new step drops them. + cleared: Option>, + /// [`HistoryState::id`] of the current state. + current_id: u64, + /// The id the next state gets: never reused, so a discarded state's id names no other. + next_id: u64, + /// The History Brush's source state, set in the History panel (`None`: the default). + brush_source: Option, } +/// The undo and redo states [`History::clear_states`] removed, and the History Brush source set +/// before it. +#[derive(Clone, Debug)] +struct Cleared { + undo: VecDeque, + redo: Vec, + brush_source: Option, +} + +/// The undo label of the step [`History::clear_states`] adds (Edit › Undo Clear History). +pub const CLEAR_HISTORY_LABEL: &str = "Clear History"; + impl Default for History { fn default() -> Self { Self::new(50) @@ -68,6 +91,10 @@ impl History { max_bytes: 0, current_label: "Open".into(), current_layers: LayerTarget::default(), + cleared: None, + current_id: 0, + next_id: 1, + brush_source: None, } } @@ -77,15 +104,23 @@ impl History { if self.max_bytes == 0 { self.tiles = None; } + // The tile counter also counted the cleared states this step forgets: rebuild it lazily. + if self.cleared.take().is_some() { + self.tiles = None; + } if let Some(tiles) = &mut self.tiles { tiles.add(&before); } let prev_label = std::mem::replace(&mut self.current_label, label.into()); let prev_layers = std::mem::replace(&mut self.current_layers, layers); - self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers }); + let prev_id = std::mem::replace(&mut self.current_id, self.next_id); + self.next_id = self.next_id.wrapping_add(1); + self.undo.push_back(HistoryState { label: prev_label, doc: before, layers: prev_layers, id: prev_id }); self.clear_redo(); while self.undo.len() > self.max_states { - if let Some(state) = self.undo.pop_front() + // The History Brush's source doesn't fall off (as in Photoshop): the next oldest does. + let pinned = self.max_states > 1 && self.undo.front().is_some_and(|s| Some(s.id) == self.brush_source); + if let Some(state) = self.undo.remove(usize::from(pinned)) && let Some(tiles) = &mut self.tiles { tiles.remove(&state.doc); @@ -94,12 +129,15 @@ impl History { } pub fn can_undo(&self) -> bool { - !self.undo.is_empty() + !self.undo.is_empty() || self.cleared.is_some() } pub fn can_redo(&self) -> bool { !self.redo.is_empty() } pub fn undo_label(&self) -> Option<&str> { + if self.cleared.is_some() { + return Some(CLEAR_HISTORY_LABEL); + } self.can_undo().then_some(self.current_label.as_str()) } pub fn redo_label(&self) -> Option<&str> { @@ -119,6 +157,15 @@ impl History { /// Undo: `current` becomes redoable; returns the document to make current and the layers it /// targeted when it was created. pub fn undo(&mut self, current: Arc) -> Option<(Arc, LayerTarget)> { + // Undo Clear History: the cleared states come back; the document doesn't change. + if let Some(cleared) = self.cleared.take() { + let Cleared { undo, redo, brush_source } = *cleared; + self.undo = undo; + self.redo = redo; + self.brush_source = brush_source; + self.tiles = None; + return Some((current, self.current_layers.clone())); + } let prev = self.undo.pop_back()?; if let Some(tiles) = &mut self.tiles { tiles.remove(&prev.doc); @@ -126,7 +173,8 @@ impl History { } let label = std::mem::replace(&mut self.current_label, prev.label); let layers = std::mem::replace(&mut self.current_layers, prev.layers.clone()); - self.redo.push(HistoryState { label, doc: current, layers }); + let id = std::mem::replace(&mut self.current_id, prev.id); + self.redo.push(HistoryState { label, doc: current, layers, id }); Some((prev.doc, prev.layers)) } @@ -139,7 +187,8 @@ impl History { } let label = std::mem::replace(&mut self.current_label, next.label); let layers = std::mem::replace(&mut self.current_layers, next.layers.clone()); - self.undo.push_back(HistoryState { label, doc: current, layers }); + let id = std::mem::replace(&mut self.current_id, next.id); + self.undo.push_back(HistoryState { label, doc: current, layers, id }); Some((next.doc, next.layers)) } @@ -154,6 +203,59 @@ impl History { self.undo.get(i).map(|s| s.doc.clone()) } + /// Id of History panel row `i`: the [`History::entries`] (0 = oldest, then the current state), + /// then the redo states, the next redo first ([`History::redo_labels`]). `None` past the end. + pub fn row_id(&self, i: usize) -> Option { + let past = self.undo.len(); + match i.checked_sub(past) { + None => self.undo.get(i).map(|s| s.id), + Some(0) => Some(self.current_id), + Some(k) => self.redo.iter().rev().nth(k - 1).map(|s| s.id), + } + } + + /// The History panel row of the state with id `id`, if it is still held. + pub fn row_of(&self, id: u64) -> Option { + let past = self.undo.len(); + if id == self.current_id { + return Some(past); + } + if let Some(i) = self.undo.iter().position(|s| s.id == id) { + return Some(i); + } + self.redo.iter().rev().position(|s| s.id == id).map(|k| past + 1 + k) + } + + /// Document of History panel row `i` (see [`History::row_id`]); `current` is the current + /// document, which the history does not hold. + pub fn row_doc(&self, i: usize, current: &Arc) -> Option> { + let past = self.undo.len(); + match i.checked_sub(past) { + None => self.state(i), + Some(0) => Some(current.clone()), + Some(k) => self.redo.iter().rev().nth(k - 1).map(|s| s.doc.clone()), + } + } + + /// Make row `i` the History Brush's source (the History panel's source column). It stays on + /// that state while older states fall off, and the state itself is kept when it would fall off + /// the history (by count or memory); `false` if there is no such row. + pub fn set_brush_source(&mut self, i: usize) -> bool { + let Some(id) = self.row_id(i) else { return false }; + self.brush_source = Some(id); + true + } + + /// The History Brush's source row: the oldest state held by default, else the state set with + /// [`History::set_brush_source`]. `None` once that state is gone (deleted, or discarded by a + /// new step after stepping back): as in Photoshop, there is then no source until one is set. + pub fn brush_source(&self) -> Option { + match self.brush_source { + None => Some(0), + Some(id) => self.row_of(id), + } + } + /// Number of past states (entries before the current one). pub fn past_len(&self) -> usize { self.undo.len() @@ -168,6 +270,10 @@ impl History { /// last step can no longer be undone and its pixels are released. pub fn purge_last(&mut self) -> bool { self.clear_redo(); + if self.cleared.take().is_some() { + self.tiles = None; + return true; + } let Some(state) = self.undo.pop_back() else { return false }; if let Some(tiles) = &mut self.tiles { tiles.remove(&state.doc); @@ -185,17 +291,64 @@ impl History { self.redo.clear(); } + /// Forget every past and redo state (Edit › Purge › Histories). The History Brush's source + /// goes back to the default, the oldest state held (now the current one): a source set on a + /// state the purge removed would otherwise leave no source. pub fn clear(&mut self) { self.undo.clear(); self.redo.clear(); self.tiles = None; + self.cleared = None; + self.reset_brush_source(); + } + + /// Make the History Brush's source the default again (the oldest state held), pinning no state. + pub fn reset_brush_source(&mut self) { + self.brush_source = None; + } + + /// History panel › Clear History: take every state but the current one off the list without + /// changing the document. Unlike [`Self::clear`] (Edit › Purge › Histories) it is undoable: + /// the next [`Self::undo`] puts the states back, and the next recorded step forgets them. + /// The History Brush's source goes back to the default until that undo, as in [`Self::clear`]. + /// Returns false (and changes nothing) when the current state is the only one. + pub fn clear_states(&mut self) -> bool { + if self.undo.is_empty() && self.redo.is_empty() { + return false; + } + let undo = std::mem::take(&mut self.undo); + let redo = std::mem::take(&mut self.redo); + let brush_source = self.brush_source.take(); + self.cleared = Some(Box::new(Cleared { undo, redo, brush_source })); + self.tiles = None; + true + } + + /// Whether the last change to the list was Clear History (so undo restores the states). + pub fn is_cleared(&self) -> bool { + self.cleared.is_some() + } + + /// The undo and redo states history holds: the cleared ones while Clear History can still be + /// undone (the list is then empty), so memory figures and the byte budget count them. + fn held(&self) -> (&VecDeque, &Vec) { + match &self.cleared { + Some(c) => (&c.undo, &c.redo), + None => (&self.undo, &self.redo), + } + } + + /// Every state history holds, cleared ones included (see [`Self::held`]). + fn held_states(&self) -> impl Iterator { + let (undo, redo) = self.held(); + undo.iter().chain(redo.iter()) } /// Approximate unique pixel bytes held by history (tiles not shared with `current`). pub fn unique_bytes(&self, current: &Document) -> usize { let mut seen = HashSet::new(); tile_bytes(current, &mut seen); - self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum() + self.held_states().map(|s| tile_bytes(&s.doc, &mut seen)).sum() } /// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`] @@ -206,7 +359,7 @@ impl History { } let mut seen = HashSet::new(); let own = tile_bytes(current, &mut seen); - self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) + self.held_states().fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) } /// Bytes of the tiles only `extra` documents hold (History panel snapshots, #4530): not @@ -217,7 +370,7 @@ impl History { } let mut seen = HashSet::new(); tile_bytes(current, &mut seen); - for s in self.undo.iter().chain(self.redo.iter()) { + for s in self.held_states() { tile_bytes(&s.doc, &mut seen); } extra.iter().fold(0, |n, d| n.saturating_add(tile_bytes(d, &mut seen))) @@ -231,13 +384,15 @@ impl History { for d in keep { tile_bytes(d, &mut seen); } - self.undo.iter().chain(self.redo.iter()).fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) + self.held_states().fold(0, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen))) } /// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the /// tiles only history holds (newest states first). The oldest undo states that don't fit are - /// dropped; the most recent one is always kept so the last step can be undone. Returns how - /// many states were dropped. + /// dropped; the most recent one is always kept so the last step can be undone. The History + /// Brush's source state ([`History::set_brush_source`]) is never dropped: its unique tiles are + /// counted first, and the newest states fill what remains. Returns how many states were + /// dropped. pub fn trim(&mut self, current: &Document) -> usize { self.trim_with(current, &[]) } @@ -246,7 +401,13 @@ impl History { /// snapshots, which outlive the states) against the budget too, so they can't take memory /// beyond it unseen (#4530). The most recent state is still always kept. pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize { - if self.max_bytes == 0 || self.undo.len() <= 1 { + // While Clear History can be undone, the cleared states are what history holds: the + // oldest of them go first, as the list's would. + let (undo, redo) = match &mut self.cleared { + Some(c) => (&mut c.undo, &c.redo), + None => (&mut self.undo, &self.redo), + }; + if self.max_bytes == 0 || undo.len() <= 1 { return 0; } // An upper bound of what only `pinned` holds (some of it may be in states too): if the @@ -260,7 +421,7 @@ impl History { }; let tiles = self.tiles.get_or_insert_with(|| { let mut tiles = tiles::Tiles::default(); - for state in self.undo.iter().chain(&self.redo) { + for state in undo.iter().chain(redo) { tiles.add(&state.doc); } tiles @@ -273,22 +434,38 @@ impl History { for d in pinned { total = total.saturating_add(tile_bytes(d, &mut seen)); } - for s in self.redo.iter().rev() { + for s in redo.iter().rev() { total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); } + // The History Brush's source is kept whatever its age, so its own tiles come first; the + // newest states then fill what remains of the budget (the most recent one always). + let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id)); + if let Some(state) = source.and_then(|i| undo.get(i)) { + total = total.saturating_add(tile_bytes(&state.doc, &mut seen)); + } let mut keep = 0; - for s in self.undo.iter().rev() { + for (i, s) in undo.iter().enumerate().rev() { + if Some(i) == source { + continue; + } total = total.saturating_add(tile_bytes(&s.doc, &mut seen)); if total > self.max_bytes && keep >= 1 { break; } keep += 1; } - let drop = self.undo.len() - keep; - for state in self.undo.drain(..drop) { - tiles.remove(&state.doc); + let mut kept = VecDeque::with_capacity(keep + 1); + let mut dropped = 0; + for (i, state) in std::mem::take(undo).into_iter().enumerate().rev() { + if Some(i) == source || kept.len() < keep + usize::from(source.is_some_and(|j| j > i)) { + kept.push_front(state); + } else { + tiles.remove(&state.doc); + dropped += 1; + } } - drop + *undo = kept; + dropped } } @@ -381,6 +558,131 @@ mod tests { assert_eq!(h.redo_labels().collect::>(), ["A", "B"], "next redo first"); } + /// The History Brush source is a state, not an index: it stays on its state through undo, + /// redo and older states falling off, is kept when it would fall off itself, and is gone + /// (no source) once a new step discards it. + #[test] + fn brush_source_follows_its_state() { + let mut h = History::new(3); + let mut cur = Arc::new(base()); + assert_eq!(h.brush_source(), Some(0), "default: the oldest state"); + assert!(!h.set_brush_source(1), "no such row yet"); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert!(h.set_brush_source(1)); + assert_eq!(h.row_doc(1, &cur).unwrap().name, "a"); + // Undo / redo: the rows stay put, and A's state is still row 1. + cur = h.undo(cur).unwrap().0; + assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); + assert_eq!(h.row_doc(2, &cur).unwrap().name, "b", "a redo row"); + cur = h.redo(cur).unwrap().0; + // Open falls off the 3-state limit: A's state moves up to row 0 and stays the source. + edit(&mut h, &mut cur, "C", |d| d.name = "c".into()); + edit(&mut h, &mut cur, "D", |d| d.name = "d".into()); + assert_eq!(h.entries(), ["A", "B", "C", "D"]); + assert_eq!(h.brush_source(), Some(0)); + // A would fall off next: it is kept, and the states after it fall off instead. + edit(&mut h, &mut cur, "E", |d| d.name = "e".into()); + edit(&mut h, &mut cur, "F", |d| d.name = "f".into()); + assert_eq!(h.entries(), ["A", "D", "E", "F"]); + assert_eq!((h.brush_source(), h.row_doc(0, &cur).unwrap().name.as_str()), (Some(0), "a")); + // A redo state as the source, discarded by a new step: no source until one is set. + cur = h.undo(cur).unwrap().0; + assert!(h.set_brush_source(3), "the greyed F row"); + assert_eq!((h.brush_source(), h.row_doc(3, &cur).unwrap().name.as_str()), (Some(3), "f")); + assert!(h.row_doc(4, &cur).is_none() && h.row_id(4).is_none()); + edit(&mut h, &mut cur, "G", |d| d.name = "g".into()); + assert_eq!(h.brush_source(), None); + // The current state as the source keeps it once it becomes a past state; once it is no + // longer the source, it falls off as usual. + assert!(h.set_brush_source(3)); + edit(&mut h, &mut cur, "H", |d| d.name = "h".into()); + assert_eq!((h.brush_source(), h.row_doc(2, &cur).unwrap().name.as_str()), (Some(2), "g")); + assert!(h.set_brush_source(1)); + edit(&mut h, &mut cur, "I", |d| d.name = "i".into()); + assert_eq!(h.entries(), ["E", "G", "H", "I"]); + } + + #[test] + fn byte_budget_keeps_the_brush_source() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + assert!(h.set_brush_source(1)); + h.max_bytes = 1; + let dropped = h.trim(&cur); + assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step"); + assert_eq!(dropped, 3); + assert_eq!(h.brush_source(), Some(0)); + } + + /// The pinned source counts against the byte budget: with room for three states, the + /// source and the two newest are kept, never more than the budget (#3536 review). + #[test] + fn byte_budget_counts_the_brush_source_first() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + let tile = h.pixel_bytes(&cur); + assert!(tile > 0); + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + // Every state holds one tile of its own; the source is the oldest (Open). + assert!(h.set_brush_source(0)); + h.max_bytes = tile * 4; + assert_eq!(h.trim(&cur), 2); + assert_eq!(h.entries(), ["Open", "paint 2", "paint 3", "paint 4"]); + assert!(h.pixel_bytes(&cur) <= h.max_bytes, "{} > {}", h.pixel_bytes(&cur), h.max_bytes); + assert_eq!(h.trim(&cur), 0, "it fits now"); + // A new step trims to the budget again, still keeping the source. + edit(&mut h, &mut cur, "paint 5", |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]); + }); + assert_eq!(h.trim(&cur), 1); + assert_eq!(h.entries(), ["Open", "paint 3", "paint 4", "paint 5"]); + assert!(h.pixel_bytes(&cur) <= h.max_bytes); + assert_eq!(h.brush_source(), Some(0)); + } + + /// Edit › Purge › Histories clears every state: a source set on one of them goes back to the + /// default (the oldest state held) instead of leaving no source (#3536 review). + #[test] + fn clear_resets_the_brush_source() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert!(h.set_brush_source(1)); + h.clear(); + assert_eq!(h.brush_source(), Some(0)); + assert_eq!(h.row_doc(0, &cur).unwrap().name, "b"); + } + + /// History panel › Clear History leaves a usable (default) source, and its undo restores the + /// source set on a past state. + #[test] + fn clear_states_resets_the_brush_source_until_undo() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert!(h.set_brush_source(1)); + assert!(h.clear_states()); + assert_eq!(h.brush_source(), Some(0)); + assert_eq!(h.row_doc(0, &cur).unwrap().name, "b"); + cur = h.undo(cur).unwrap().0; + assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a")); + } + #[test] fn new_edit_clears_redo() { let mut h = History::default(); @@ -609,7 +911,7 @@ mod tests { assert_eq!(counter.bytes_with(current), total as u128); assert_eq!(h.pixel_bytes(current), total); let mut seen = HashSet::new(); - let retained: usize = h.undo.iter().chain(&h.redo).map(|s| tile_bytes(&s.doc, &mut seen)).sum(); + let retained: usize = h.held_states().map(|s| tile_bytes(&s.doc, &mut seen)).sum(); let empty = Document::new("empty", Size::new(1, 1), ColorMode::Rgb, SampleType::U8); assert_eq!(counter.bytes_with(&empty), retained as u128); } @@ -849,4 +1151,86 @@ mod tests { assert_eq!(h.trim(¤t), 1, "current alone exceeds the budget; only the mandatory undo survives"); assert_counter(&h, ¤t); } + + #[test] + fn clear_states_keeps_the_document_and_undo_brings_the_list_back() { + let mut h = History::new(10); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + edit(&mut h, &mut cur, "C", |d| d.name = "c".into()); + let (d, _) = h.undo(cur.clone()).unwrap(); + cur = d; + let entries = h.entries(); + assert_eq!(entries, ["Open", "A", "B"]); + assert!(h.clear_states()); + assert_eq!(h.entries(), ["B"], "only the current state is left"); + assert!(!h.can_redo() && h.can_undo() && h.is_cleared()); + assert_eq!(h.undo_label(), Some(CLEAR_HISTORY_LABEL)); + assert!(!h.clear_states(), "nothing left to clear"); + assert!(h.is_cleared(), "and the cleared states are still held"); + let (d, _) = h.undo(cur.clone()).unwrap(); + assert!(Arc::ptr_eq(&d, &cur), "undoing Clear History doesn't change the document"); + assert_eq!(h.entries(), entries); + assert_eq!(h.redo_labels().collect::>(), ["C"], "the redo state is back too"); + assert!(!h.is_cleared()); + } + + #[test] + fn cleared_states_still_count_in_the_memory_figures_and_the_budget() { + let mut h = History::new(50); + let mut cur = Arc::new(base()); + let tile = 256 * 256 * 4; + for i in 0..5 { + edit(&mut h, &mut cur, &format!("paint {i}"), |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]); + }); + } + cur = h.undo(cur).unwrap().0; + let (unique, pixels) = (h.unique_bytes(&cur), h.pixel_bytes(&cur)); + assert_eq!(unique, 5 * tile, "four undo states and one redo state"); + assert!(h.clear_states()); + assert_eq!(h.unique_bytes(&cur), unique, "Clear History frees nothing while it can be undone"); + assert_eq!(h.pixel_bytes(&cur), pixels); + assert_eq!(h.extra_bytes(&cur, &[&base()]), tile, "a snapshot's own tile, not the cleared states'"); + assert_eq!(h.unique_bytes_besides(&cur, &[]), unique); + // The budget counts them too: the oldest cleared states go first. + h.max_bytes = 4 * tile; + assert_eq!(h.trim(&cur), 2); + assert_counter(&h, &cur); + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared"); + h.undo(cur.clone()).unwrap(); + assert_eq!(h.entries(), ["paint 1", "paint 2", "paint 3"], "undo brings back what the budget kept"); + assert_eq!(h.redo_labels().collect::>(), ["paint 4"]); + // A new step after Clear History forgets the cleared states, and the counter with them. + assert!(h.clear_states()); + h.trim(&cur); + edit(&mut h, &mut cur, "paint 5", |d| { + let id = d.layers[0].id; + d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]); + }); + assert_eq!(h.unique_bytes(&cur), tile, "only the state before the new step"); + assert_eq!(h.pixel_bytes(&cur), 2 * tile); + } + + #[test] + fn a_new_step_or_purge_forgets_the_cleared_states() { + let mut h = History::new(10); + let mut cur = Arc::new(base()); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + assert!(h.clear_states()); + edit(&mut h, &mut cur, "B", |d| d.name = "b".into()); + assert_eq!(h.entries(), ["A", "B"]); + assert!(!h.is_cleared()); + assert!(h.undo(cur.clone()).is_some()); + assert!(!h.can_undo(), "the cleared states are gone"); + let mut h = History::new(10); + edit(&mut h, &mut cur, "A", |d| d.name = "a".into()); + assert!(h.clear_states()); + assert!(h.purge_last()); + assert!(!h.can_undo() && !h.is_cleared()); + assert!(!h.clear_states(), "a single state can't be cleared"); + } } diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs index ec0ce0907b..323a90036a 100644 --- a/crates/paint/src/bg_erase.rs +++ b/crates/paint/src/bg_erase.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::Stroke; use crate::replace::{Limits, Sampling}; -use crate::retouch::{Region, alpha_index, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, alpha_index, apply_dab_stroke}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -49,16 +49,34 @@ fn dist(a: &[f32], b: &[f32]) -> f32 { /// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no /// alpha channel. pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { - let fmt = target.format(); - let Some(a) = alpha_index(&fmt) else { return Rect::EMPTY }; + let Some(effect) = background_eraser_effect(target, bs) else { return Rect::EMPTY }; + apply_dab_stroke(target, &background_eraser_stroke(stroke), selection, false, 0, effect) +} + +/// The stroke [`apply_background_eraser`] runs its dabs along: full opacity (the tool has none), +/// never the brush's erase flag. Live previews run the same dabs. +pub fn background_eraser_stroke(stroke: &Stroke) -> Stroke { + let mut stroke = stroke.clone(); + stroke.brush.opacity = 1.0; + stroke.brush.erase = false; + stroke +} + +/// One Background Eraser dab's edit of a working copy of `pre` (the target before the stroke): +/// shared by [`apply_background_eraser`] and live previews that run the dabs as they come, so both +/// erase the same pixels. `None` when `pre` has no alpha channel (nothing can be erased). +pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option> { + let fmt = pre.format(); + let a = alpha_index(&fmt)?; let nc = a; let tol = if bs.tolerance.is_finite() { bs.tolerance.clamp(0.0, 1.0) } else { 0.0 }; let soft = bs.limits != Limits::FindEdges; let native = |c: [f32; 4]| -> Vec { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() }; let swatch = native(bs.background); let fg = native(bs.foreground); + let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground); let mut once: Option>> = None; - let match_w = |d: f32| -> f32 { + let match_w = move |d: f32| -> f32 { if d > tol { 0.0 } else if soft && tol > 0.0 { @@ -68,18 +86,20 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra 1.0 } }; - let mut stroke = stroke.clone(); - stroke.brush.opacity = 1.0; - stroke.brush.erase = false; - // The pixels before the stroke, taken at the first dab. Sampling and matching read these, so a - // dab landing on pixels earlier dabs already erased still samples and matches (#1280). + let pre = pre.clone(); + // The pixels before the stroke under the working copy, read once per working area. Sampling + // and matching read these, so a dab landing on pixels earlier dabs already erased still + // samples and matches (#1280). let mut orig: Option = None; - apply_dab_stroke(target, &stroke, selection, false, 0, |work: &mut Region, fp| { + Some(move |work: &mut Region, fp: &Footprint| { let r = fp.rect.intersect(&work.rect); if r.is_empty() { return; } - let orig = &*orig.get_or_insert_with(|| work.clone()); + if orig.as_ref().is_none_or(|o| o.rect != work.rect) { + orig = Some(Region::read(&pre, work.rect)); + } + let Some(orig) = orig.as_ref() else { return }; let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); // The hotspot's colour (None where it was transparent before the stroke or is off the // working area). @@ -90,7 +110,7 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra let p = orig.px(cx, cy); (p[a] > 0.0).then(|| p[..nc].to_vec()) }; - let sampled = match bs.sampling { + let sampled = match sampling { Sampling::Continuous => hotspot(), Sampling::Once => once.get_or_insert_with(hotspot).clone(), Sampling::BackgroundSwatch => Some(swatch.clone()), @@ -110,13 +130,13 @@ pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEra continue; } let c = &p[..nc]; - if bs.protect_foreground && dist(c, &fg) <= tol { + if protect_foreground && dist(c, &fg) <= tol { continue; } weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled)); } } - if bs.limits != Limits::Discontiguous { + if limits != Limits::Discontiguous { keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); } for y in r.y0..r.y1 { diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 7695ec3a60..fc61e41152 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -410,9 +410,9 @@ pub struct SectionLocks { pub protect_texture: bool, } -/// Brush Pose: stylus values used instead of (override) or for missing device data. +/// Brush Pose: fixed values used for the selected stylus-component overrides. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] +#[serde(from = "PoseWire", into = "PoseWire")] pub struct Pose { pub enabled: bool, /// Degrees, -90..90 (W3C Pointer Events convention). @@ -422,7 +422,8 @@ pub struct Pose { pub rotation: f32, /// 0..1. pub pressure: f32, - pub override_tilt: bool, + pub override_tilt_x: bool, + pub override_tilt_y: bool, pub override_rotation: bool, pub override_pressure: bool, } @@ -435,13 +436,88 @@ impl Default for Pose { tilt_y: 0.0, rotation: 0.0, pressure: 1.0, - override_tilt: false, + override_tilt_x: false, + override_tilt_y: false, override_rotation: false, override_pressure: false, } } } +// Old presets used one combined flag. Keep that JSON input and readback while the +// model has only the two independent sources of truth. Explicit axis fields win. +#[derive(Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PoseWire { + enabled: bool, + tilt_x: f32, + tilt_y: f32, + rotation: f32, + pressure: f32, + override_tilt: bool, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_x: Option, + #[serde(default, deserialize_with = "pose_axis_bool")] + override_tilt_y: Option, + override_rotation: bool, + override_pressure: bool, +} + +// Missing axis keys migrate from the old flag; a present null/non-boolean is an error. +fn pose_axis_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + bool::deserialize(d).map(Some) +} + +impl Default for PoseWire { + fn default() -> Self { + Self { + enabled: false, + tilt_x: 0.0, + tilt_y: 0.0, + rotation: 0.0, + pressure: 1.0, + override_tilt: false, + override_tilt_x: None, + override_tilt_y: None, + override_rotation: false, + override_pressure: false, + } + } +} + +impl From for Pose { + fn from(p: PoseWire) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt_x: p.override_tilt_x.unwrap_or(p.override_tilt), + override_tilt_y: p.override_tilt_y.unwrap_or(p.override_tilt), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + +impl From for PoseWire { + fn from(p: Pose) -> Self { + Self { + enabled: p.enabled, + tilt_x: p.tilt_x, + tilt_y: p.tilt_y, + rotation: p.rotation, + pressure: p.pressure, + override_tilt: p.override_tilt_x && p.override_tilt_y, + override_tilt_x: Some(p.override_tilt_x), + override_tilt_y: Some(p.override_tilt_y), + override_rotation: p.override_rotation, + override_pressure: p.override_pressure, + } + } +} + /// Stroke smoothing (Photoshop's Smoothing % and its options). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 2365d5e586..d71e245fc5 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -155,6 +155,8 @@ fn pencil_dabs_sit_on_the_pixel_grid() { // Odd sizes centre on the pixel holding the point, even ones on the nearest corner. assert_eq!(grid_center(10.0, 5.0, 1.0), (10.5, 5.5)); assert_eq!(grid_center(10.9, 5.2, 3.0), (10.5, 5.5)); + assert_eq!(grid_center(10.0 - 5e-8, 5.0 - 5e-8, 1.0), (10.5, 5.5)); + assert_eq!(grid_center(10.0 - 2e-7, 5.0 - 2e-7, 1.0), (9.5, 4.5)); assert_eq!(grid_center(10.4, 5.6, 2.0), (10.0, 6.0)); assert_eq!(grid_square(10.2, 5.7, 1.0), [10.0, 5.0, 11.0, 6.0]); assert_eq!(grid_square(10.2, 5.7, 4.0), [8.0, 4.0, 12.0, 8.0]); @@ -939,6 +941,133 @@ fn a_pulled_string_brush_takes_the_pose_along_the_string() { assert!((p.rotation - 6.0).abs() < 1e-3, "{}", p.rotation); } +#[test] +fn a_smoothed_brush_catches_up_with_a_pen_lifted_in_place() { + // #7747: only the pressure changes while the pointer stands still. The brush lags in pose, + // so catch-up must keep running and the stroke end at the pen's last pressure. + let cfg = Smoothing { amount: 0.8, ..Default::default() }; + let mut s = Smoother::new(&cfg, 1.0); + let mut out = Vec::new(); + s.push(StrokePoint::new(50.0, 50.0, 1.0), &mut out); + s.push(StrokePoint { time: 32.0, ..StrokePoint::new(50.0, 50.0, 0.0) }, &mut out); + assert!(out.last().unwrap().pressure > 0.5, "the brush trails in pressure: {:?}", out.last()); + assert!(s.lagging(), "a pose behind the pointer is still catching up"); + // Held long enough, the brush arrives and stops asking for time. + for i in 1..=200 { + s.push(StrokePoint { time: 32.0 + f64::from(i) * 16.0, ..StrokePoint::new(50.0, 50.0, 0.0) }, &mut out); + } + assert!(!s.lagging(), "{:?}", out.last()); + s.finish(&mut out); + assert_eq!(out.last().unwrap().pressure, 0.0); +} + +#[test] +fn catch_up_on_stroke_end_finishes_the_pose_of_a_still_pen() { + // #7747: tilt, rotation and wheel changed in place; the end catch-up lands on all of them. + let cfg = Smoothing { amount: 0.8, ..Default::default() }; + let a = StrokePoint { rotation: 350.0, ..StrokePoint::new(50.0, 50.0, 1.0) }; + let b = StrokePoint { time: 16.0, tilt_x: 40.0, tilt_y: -20.0, rotation: 10.0, wheel: 0.5, ..StrokePoint::new(50.0, 50.0, 1.0) }; + let mut s = Smoother::new(&cfg, 1.0); + let mut out = Vec::new(); + s.push(a, &mut out); + s.push(b, &mut out); + assert!(s.lagging()); + s.finish(&mut out); + let p = out.last().unwrap(); + assert_eq!((p.tilt_x, p.tilt_y, p.rotation, p.wheel), (40.0, -20.0, 10.0, 0.5)); + // Nothing left to catch up: no extra point. + let n = out.len(); + s.finish(&mut out); + assert_eq!(out.len(), n); +} + +#[test] +fn pose_axes_override_only_the_selected_stylus_components() { + let input = StrokePoint { x: 20.0, y: 30.0, pressure: 0.71, tilt_x: 13.0, tilt_y: -27.0, rotation: 67.0, wheel: 0.24, time: 73.0 }; + for enabled in [false, true] { + for (x, y) in [(false, false), (true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, + "overrideTiltX": x, "overrideTiltY": y, + "pressure": 0.4, "rotation": 120 + }})) + .unwrap(); + let mut expected = input; + if enabled && x { + expected.tilt_x = 45.0; + } + if enabled && y { + expected.tilt_y = -18.0; + } + assert_eq!(crate::dynamics::apply_pose(&b, input), expected, "enabled={enabled}, X={x}, Y={y}"); + } + } +} + +#[test] +fn pose_axes_reach_dabs_and_pixels_at_all_rgba_depths() { + let points = [ + StrokePoint { tilt_x: 13.0, tilt_y: -27.0, ..StrokePoint::new(20.0, 30.0, 0.71) }, + StrokePoint { tilt_x: -31.0, tilt_y: 42.0, ..StrokePoint::new(75.0, 45.0, 0.83) }, + ]; + for (x, y) in [(true, false), (false, true), (true, true)] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({ + "size": 20, "roundness": 0.47, "angle": -23, + "pressureSize": false, "smoothing": {"enabled": false}, + "shapeDynamics": {"enabled": true, "brushProjection": true}, + "pose": {"enabled": true, "tiltX": 45, "tiltY": -18, "overrideTiltX": x, "overrideTiltY": y} + })) + .unwrap(); + let expected_points: Vec<_> = + points.iter().map(|p| StrokePoint { tilt_x: if x { 45.0 } else { p.tilt_x }, tilt_y: if y { -18.0 } else { p.tilt_y }, ..*p }).collect(); + let mut expected_brush = b.clone(); + expected_brush.pose.enabled = false; + let actual_dabs = dabs_of(&b, &points); + let expected_dabs = dabs_of(&expected_brush, &expected_points); + assert_eq!(actual_dabs.len(), expected_dabs.len()); + for (index, (actual, expected)) in actual_dabs.iter().zip(&expected_dabs).enumerate() { + assert_eq!(actual, expected, "resolved tilt reaches dab {index}"); + } + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut actual = Surface::new(format); + let mut expected = Surface::new(format); + render_stroke(&mut actual, &b, &points, None, false, 1.0); + render_stroke(&mut expected, &expected_brush, &expected_points, None, false, 1.0); + assert!(actual.rgba(20, 30)[3] > 0.0, "the comparison includes painted pixels"); + for py in 0..80 { + for px in 0..100 { + assert_eq!(actual.rgba(px, py), expected.rgba(px, py), "{format:?}, X={x}, Y={y}, pixel ({px},{py})"); + } + } + } + } +} + +#[test] +fn pose_axes_legacy_combined_override_and_existing_rotation_pressure_are_preserved() { + let input = StrokePoint { tilt_x: 13.0, tilt_y: -27.0, pressure: 0.71, rotation: 67.0, ..Default::default() }; + for enabled in [false, true] { + for legacy in [false, true] { + let b: BrushSettings = serde_json::from_value(serde_json::json!({"pose": { + "enabled": enabled, "tiltX": 45, "tiltY": -18, "overrideTilt": legacy, + "pressure": 0.4, "overridePressure": true, "rotation": 120, "overrideRotation": true + }})) + .unwrap(); + let expected = StrokePoint { + tilt_x: if enabled && legacy { 45.0 } else { input.tilt_x }, + tilt_y: if enabled && legacy { -18.0 } else { input.tilt_y }, + pressure: if enabled { 0.4 } else { input.pressure }, + rotation: if enabled { 120.0 } else { input.rotation }, + ..input + }; + assert_eq!(crate::dynamics::apply_pose(&b, input), expected); + let stored = serde_json::to_vec(&b).unwrap(); + let restored: BrushSettings = serde_json::from_slice(&stored).unwrap(); + assert_eq!(crate::dynamics::apply_pose(&restored, input), expected, "legacy brush settings retain their painting behavior through persistence"); + } + } +} + #[test] fn pose_overrides_stylus_values() { let b = BrushSettings { diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 0c535cf89c..41c1bb6b4d 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -29,8 +29,8 @@ const MAX_AIRBRUSH_DABS_PER_SEGMENT: usize = 512; /// The dab centre of the pixel-grid cell holding `v` (see [`PathWalker::pixel_grid`]): the /// pixel's centre for `offset` 0.5, the nearest pixel corner for 0. A value within rounding -/// noise of a cell boundary counts as on it, so a line lands on the same pixels both ways. -fn grid_cell(v: f64, offset: f64) -> f64 { +/// noise of a cell boundary counts as on it, so snapping and line stepping agree. +pub(crate) fn grid_cell(v: f64, offset: f64) -> f64 { let w = v - offset + 0.5; let w = if (w - w.round()).abs() < 1e-7 { w.round() } else { w }; w.floor() + offset @@ -76,6 +76,18 @@ fn pull(brush: &StrokePoint, p: &StrokePoint, f: f64) -> StrokePoint { } } +/// Does the brush's pose (pressure, tilt, rotation, wheel) differ from `p`'s by more than `eps`? +/// [`pull`] moves the pose with the position, so a pen that only changes pressure or tilt in place +/// leaves the brush behind too (#7747). +fn pose_differs(brush: &StrokePoint, p: &StrokePoint, eps: f32) -> bool { + let dr = (p.rotation - brush.rotation + 540.0).rem_euclid(360.0) - 180.0; + (p.pressure - brush.pressure).abs() > eps + || (p.tilt_x - brush.tilt_x).abs() > eps + || (p.tilt_y - brush.tilt_y).abs() > eps + || dr.abs() > eps + || (p.wheel - brush.wheel).abs() > eps +} + /// Incremental stroke smoothing: exponential (with optional time-based catch-up) or pulled string. #[derive(Clone, Debug)] pub struct Smoother { @@ -153,7 +165,7 @@ impl Smoother { /// stroke needs time to keep passing (repeats of the last point) while the pointer is held. pub fn lagging(&self) -> bool { match (self.active() && self.cfg.catch_up && !self.cfg.pulled_string, self.pos, self.last_input) { - (true, Some(pos), Some(last)) => (pos.x - last.x).abs() + (pos.y - last.y).abs() > 0.01, + (true, Some(pos), Some(last)) => (pos.x - last.x).abs() + (pos.y - last.y).abs() > 0.01 || pose_differs(&pos, &last, 1e-3), _ => false, } } @@ -166,13 +178,14 @@ impl Smoother { (self.active() && (pos.x != last.x || pos.y != last.y)).then_some(([pos.x, pos.y], [last.x, last.y])) } - /// End of stroke: with Catch-Up On Stroke End the brush finishes at the last pointer position. + /// End of stroke: with Catch-Up On Stroke End the brush finishes at the last pointer position + /// and pose. pub fn finish(&mut self, out: &mut Vec) { if !self.active() || !self.cfg.catch_up_on_end { return; } if let (Some(pos), Some(last)) = (self.pos, self.last_input) - && (pos.x != last.x || pos.y != last.y) + && (pos.x != last.x || pos.y != last.y || pose_differs(&pos, &last, 0.0)) { self.pos = Some(last); out.push(last); @@ -693,8 +706,10 @@ impl DualBuilder { pub fn apply_pose(b: &BrushSettings, mut p: StrokePoint) -> StrokePoint { let pose = &b.pose; if pose.enabled { - if pose.override_tilt { + if pose.override_tilt_x { p.tilt_x = pose.tilt_x; + } + if pose.override_tilt_y { p.tilt_y = pose.tilt_y; } if pose.override_rotation { diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 2f65fc1a0b..74d74be893 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -321,6 +321,24 @@ mod tests { assert_eq!(s.pixel(70, 10)[3], 0.0); } + #[test] + fn lock_transparency_blends_source_atop_on_semi_transparent_pixels() { + // Half-transparent white under a black stroke at 50%: the colour moves halfway (Photoshop's + // source-atop), not by the source-over weight 0.5 / 0.75 (#5783). + for f in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + for mode in [photocraft_color::BlendMode::Normal, photocraft_color::BlendMode::Multiply] { + let mut s = Surface::new(f); + s.fill_rect(Rect::new(0, 0, 100, 20), &[1.0, 1.0, 1.0, 0.5]); + let mut st = stroke(&[(10.0, 10.0), (90.0, 10.0)], 8.0); + st.brush.opacity = 0.5; + st.brush.mode = mode; + apply_stroke(&mut s, &st, None, true); + let p = s.rgba(50, 10); + assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{f:?} {mode:?} {p:?}"); + } + } + } + #[test] fn pressure_changes_size() { let mut st = stroke(&[(0.0, 0.0), (100.0, 0.0)], 20.0); diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 5f59aafad1..b68e7a3190 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -15,7 +15,7 @@ use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use crate::brush::{BrushSettings, MaskMode, Pattern, TipShape}; -use crate::dynamics::DabGenerator; +use crate::dynamics::{DabGenerator, grid_cell}; use crate::retouch::{Footprint, alpha_index, over_native}; use crate::rng::hash2; use crate::tile::{Mips, PatternImage}; @@ -52,7 +52,7 @@ const NOISE_SALT: u64 = 0x006E_6F69_7365; /// the Pencil cursor shows ([`grid_square`]). pub fn grid_center(x: f64, y: f64, diameter: f32) -> (f32, f32) { let n = if diameter.is_finite() { diameter.round().max(1.0) as i64 } else { 1 }; - let snap = |v: f64| if n % 2 == 1 { v.floor() + 0.5 } else { v.round() }; + let snap = |v: f64| if n % 2 == 1 { grid_cell(v, 0.5) } else { v.round() }; (snap(x) as f32, snap(y) as f32) } @@ -1043,10 +1043,11 @@ impl StrokeRenderer { } else { let d = to_rgba(&fmt, px); let sr = to_rgba(&fmt, &src[..n]); - let mut o = photocraft_color::blend::composite(b.mode, d, sr, k); - if lock_transparency { - o[3] = d[3]; - } + let o = if lock_transparency { + photocraft_color::blend::composite_atop(b.mode, d, sr, k) + } else { + photocraft_color::blend::composite(b.mode, d, sr, k) + }; let mut enc = [0.0f32; 8]; from_rgba_into(&fmt, o, &mut enc); px.copy_from_slice(&enc[..n]); diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 023e04b5ec..dcb5a6b958 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -161,15 +161,20 @@ pub fn over_native(fmt: &PixelFormat, dst: &mut [f32], src: &[f32], k: f32, lock match alpha_index(fmt) { Some(a) => { let (da, sa) = (dst[a], src[a] * k); + if lock_transparency { + // Alpha kept: source atop, the colour moves by the source alpha alone (#5783). + for c in 0..a { + dst[c] += (src[c] - dst[c]) * sa; + } + return; + } let oa = sa + da * (1.0 - sa); if oa > 0.0 { for c in 0..a { dst[c] = (src[c] * sa + dst[c] * da * (1.0 - sa)) / oa; } } - if !lock_transparency { - dst[a] = oa; - } + dst[a] = oa; } None => { for c in 0..dst.len() { @@ -220,10 +225,8 @@ pub fn apply_coverage( } else { let d = to_rgba(&fmt, px); let s = to_rgba(&fmt, src); - let mut o = photocraft_color::blend::composite(blend, d, s, k); - if lock_transparency { - o[3] = d[3]; - } + let o = + if lock_transparency { photocraft_color::blend::composite_atop(blend, d, s, k) } else { photocraft_color::blend::composite(blend, d, s, k) }; let mut enc = [0.0f32; 8]; from_rgba_into(&fmt, o, &mut enc); px.copy_from_slice(&enc[..n]); @@ -481,6 +484,23 @@ mod tests { } } + #[test] + fn coverage_composite_under_lock_blends_source_atop() { + // Locked half-transparent white, black at coverage 0.5: 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); + s.fill_rect(Rect::new(0, 0, 4, 4), &[1.0, 1.0, 1.0, 0.5]); + let r = Rect::new(0, 0, 4, 4); + let mut paint = Region::new(r, 4); + paint.data.chunks_mut(4).for_each(|p| p.copy_from_slice(&[0.0, 0.0, 0.0, 1.0])); + apply_coverage(&mut s, r, &[1.0; 16], 0.5, None, true, &paint, mode); + let p = s.rgba(1, 1); + assert!((p[0] - 0.5).abs() < 0.01 && (p[3] - 0.5).abs() < 0.01, "{fmt:?} {mode:?} {p:?}"); + } + } + } + #[test] fn dab_stroke_runs_sequentially_and_respects_selection() { let mut s = Surface::new(PixelFormat::GRAY8); diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 57ee000471..ce6e44ccd6 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -26,7 +26,7 @@ pub mod registry; mod runtime; pub use apply::format_code; -pub use manifest::{Area, Kind, Manifest, ParamSpec}; +pub use manifest::{Area, Kind, Manifest, ParamSpec, Unit}; pub use runtime::{ABI_VERSION, Limits, Plugin}; /// A plug-in error. Every way a module can misbehave ends here, never in a panic. diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 52994bc740..081c7acf6d 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -30,12 +30,47 @@ pub enum Area { Canvas, } +/// What a `number` or `int` parameter measures (`"unit"` in the manifest). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Unit { + /// No unit (the default): a level, factor, count or percentage, the same at any image size. + #[default] + None, + /// A length in document pixels (a radius, distance, shift or size). On a reduced live preview + /// the host divides it by the reduction, so the preview matches what OK applies. + Px, +} + +impl Unit { + fn is_none(&self) -> bool { + *self == Unit::None + } +} + +/// Reads `"unit"` leniently: `"px"` is [`Unit::Px`]; anything else (an unknown string such as +/// `"pt"`, a number, `null`, an array…) is unitless, as when the field is absent. Manifests from +/// before `unit` existed could carry any value there (unknown fields were ignored), and they must +/// keep installing. +fn lenient_unit<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(match Value::deserialize(d)? { + Value::String(s) if s == "px" => Unit::Px, + _ => Unit::None, + }) +} + /// One declared parameter. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum ParamSpec { /// A real number in `min..=max` (a slider in the dialog). - Number { min: f64, max: f64, default: f64 }, + Number { + min: f64, + max: f64, + default: f64, + #[serde(default, deserialize_with = "lenient_unit", skip_serializing_if = "Unit::is_none")] + unit: Unit, + }, /// A whole number in `min..=max`. Int { #[serde(default = "int_min")] @@ -44,6 +79,8 @@ pub enum ParamSpec { max: i64, #[serde(default)] default: i64, + #[serde(default, deserialize_with = "lenient_unit", skip_serializing_if = "Unit::is_none")] + unit: Unit, }, /// A checkbox. Bool { @@ -122,7 +159,7 @@ impl Manifest { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); } match spec { - ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { + ParamSpec::Number { min, max, default, .. } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); } ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), @@ -147,7 +184,7 @@ impl Manifest { .params .iter() .map(|(k, spec)| match spec { - ParamSpec::Number { min, max, default } => format!("\"{k}\":{min}..{max}={default}"), + ParamSpec::Number { min, max, default, .. } => format!("\"{k}\":{min}..{max}={default}"), ParamSpec::Int { default, .. } => format!("\"{k}\":int={default}"), ParamSpec::Bool { default } => format!("\"{k}\":bool={default}"), ParamSpec::Choice { options, default } => { @@ -179,11 +216,11 @@ impl Manifest { let v = given.get(k).filter(|v| !v.is_null()); let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); let resolved = match spec { - ParamSpec::Number { min, max, default } => match v { + ParamSpec::Number { min, max, default, .. } => match v { None => json!((*default).clamp(*min, *max)), Some(v) => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)), }, - ParamSpec::Int { min, max, default } => match v { + ParamSpec::Int { min, max, default, .. } => match v { None => json!((*default).clamp(*min, *max)), Some(v) => { let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?; @@ -209,6 +246,61 @@ impl Manifest { } Ok(out) } + + /// Whether parameter `key` is a length in pixels (`"unit": "px"`). + pub fn is_pixel_param(&self, key: &str) -> bool { + self.pixel_params().any(|(k, ..)| k == key) + } + + /// `(key, min, max, default, is_int)` of each parameter declared `"unit": "px"`. + fn pixel_params(&self) -> impl Iterator { + self.params.iter().filter_map(|(k, spec)| match spec { + ParamSpec::Number { min, max, default, unit: Unit::Px } => Some((k.as_str(), *min, *max, *default, false)), + ParamSpec::Int { min, max, default, unit: Unit::Px } => Some((k.as_str(), *min as f64, *max as f64, *default as f64, true)), + _ => None, + }) + } + + /// The value [`Manifest::resolve_params`] would use for a pixel parameter: the given number, or + /// the default when missing, within `min..=max`. `None` for a value of the wrong type (which + /// `resolve_params` rejects) or one that isn't finite. + fn pixel_value(params: &Map, key: &str, min: f64, max: f64, default: f64) -> Option { + let v = match params.get(key).filter(|v| !v.is_null()) { + None => default, + Some(v) => v.as_f64()?, + }; + // `max`/`min` rather than `clamp`, which panics on a reversed or NaN range. + v.is_finite().then(|| v.max(min).min(max)) + } + + /// Proxy factor for a live preview with `params` on a document reduced `k` times: the largest + /// factor up to `k` at which no pixel parameter with a positive minimum, divided by it, drops + /// below that minimum. A clamped value would change the feature size (as for built-in filters, + /// #2063), so such a preview runs less reduced instead. Always at least 1. + pub fn preview_factor(&self, params: &Map, k: u32) -> u32 { + self.pixel_params().fold(k.max(1), |k, (key, min, max, default, _)| match Self::pixel_value(params, key, min, max, default) { + Some(v) if min > 0.0 => k.min((v / min).floor().max(1.0).min(f64::from(k)) as u32), + Some(v) if max < 0.0 => k.min((v / max).floor().max(1.0).min(f64::from(k)) as u32), + _ => k, + }) + } + + /// Rewrites the plug-in's `params` for a live preview on a copy of the document reduced `k` + /// times: each pixel parameter (the given value, or its default when missing) is divided by + /// `k`, rounded for `int`s and kept within its range, so the preview shows the same lengths in + /// document pixels as OK applies (#7524). Unitless parameters, unknown keys and values of the + /// wrong type (which [`Manifest::resolve_params`] rejects) are left as they are. + pub fn scale_for_preview(&self, params: &mut Map, k: u32) { + if k <= 1 { + return; + } + let k = f64::from(k); + for (key, min, max, default, int) in self.pixel_params() { + let Some(v) = Self::pixel_value(params, key, min, max, default) else { continue }; + let x = (v / k).max(min).min(max); + params.insert(key.to_string(), if int { json!(x.round().max(min).min(max) as i64) } else { json!(x) }); + } + } } /// Reads the `params` object keeping its key order (serde_json's `Map` would sort it). @@ -387,6 +479,109 @@ mod tests { assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err()); } + /// Chromatic Aberration-like: a pixel shift, a pixel int, a signed pixel offset and a unitless + /// `size` (a name the built-in filters treat as pixels). + const PIXELS: &str = r#"{"id":"org.example.fringe","name":"Fringe","kind":"filter", + "params":{"amount":{"type":"number","min":0,"max":100,"default":12,"unit":"px"}, + "radius":{"type":"int","min":2,"max":50,"default":10,"unit":"px"}, + "shift":{"type":"number","min":-40,"max":40,"default":0,"unit":"px"}, + "size":{"type":"number","min":0,"max":100,"default":50}, + "mix":{"type":"number","min":0,"max":1,"default":1,"unit":"none"}}}"#; + + #[test] + fn parses_units_and_keeps_old_manifests_unitless() { + let m = Manifest::parse(PIXELS.as_bytes()).unwrap(); + assert!(m.is_pixel_param("amount") && m.is_pixel_param("radius") && m.is_pixel_param("shift")); + assert!(!m.is_pixel_param("size") && !m.is_pixel_param("mix") && !m.is_pixel_param("missing")); + assert!(matches!(m.params[0].1, ParamSpec::Number { unit: Unit::Px, .. })); + assert!(matches!(m.params[1].1, ParamSpec::Int { unit: Unit::Px, .. })); + // No `unit` is unitless, and unitless params serialize as before (no `unit` key). + let old = Manifest::parse(FULL.as_bytes()).unwrap(); + assert!(old.params.iter().all(|(k, _)| !old.is_pixel_param(k))); + let schema = serde_json::to_value(&m).unwrap(); + assert_eq!(schema["params"]["amount"]["unit"], "px"); + assert!(schema["params"]["size"].get("unit").is_none() && schema["params"]["mix"].get("unit").is_none()); + assert_eq!(Manifest::parse(serde_json::to_string(&m).unwrap().as_bytes()).unwrap(), m); + // A unit is a `number`/`int` property; the dialog notation is unchanged by it. + assert_eq!(m.params_notation(), r#"{"amount":0..100=12,"radius":int=10,"shift":-40..40=0,"size":0..100=50,"mix":0..1=1}"#); + } + + #[test] + fn unknown_units_are_unitless() { + // Unknown fields were always ignored, so a manifest may already carry any `unit`: it must + // keep installing, unscaled, exactly as when the field is absent. + for unit in [r#""pt""#, r#""PX""#, r#""""#, "5", "true", "null", r#"["px"]"#, r#"{"px":1}"#] { + let text = format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{"r":{{"type":"number","min":0,"max":9,"default":1,"unit":{unit}}}}}}}"#); + for text in [text.clone(), text.replace(r#""type":"number""#, r#""type":"int""#)] { + let m = Manifest::parse(text.as_bytes()).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(!m.is_pixel_param("r"), "{text}"); + let mut p = json!({"r": 8}).as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p), json!({"r": 8}), "{text}"); + assert!(serde_json::to_value(&m).unwrap()["params"]["r"].get("unit").is_none(), "{text}"); + } + } + } + + #[test] + fn preview_scales_pixel_params_only() { + // #7524: at a reduction of 4, a 12 px shift must be 3 proxy pixels (12 document pixels). + let m = Manifest::parse(PIXELS.as_bytes()).unwrap(); + let given = json!({"id": "org.example.fringe", "amount": 12, "radius": 10, "shift": -20, "size": 40, "mix": 0.5}); + let mut p = given.as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!( + Value::Object(p.clone()), + json!({"id": "org.example.fringe", "amount": 3.0, "radius": 3, "shift": -5.0, "size": 40, "mix": 0.5}), + "px params divided (ints rounded, signs kept); unitless and other keys untouched" + ); + // The scaled values are what the plug-in receives. + assert_eq!(m.resolve_params(&Value::Object(p)).unwrap()["amount"], json!(3.0)); + // Missing px params get their scaled default, so the default isn't previewed unscaled. + let mut p = Map::new(); + m.scale_for_preview(&mut p, 2); + assert_eq!(Value::Object(p), json!({"amount": 6.0, "radius": 5, "shift": 0.0})); + // Never below the minimum, and values are clamped into range first, as `resolve_params` does. + let mut p = json!({"radius": 3, "amount": 1000}).as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p), json!({"radius": 2, "amount": 25.0, "shift": 0.0})); + // A full-resolution preview changes nothing. + for k in [0, 1] { + let mut p = given.as_object().unwrap().clone(); + m.scale_for_preview(&mut p, k); + assert_eq!(Value::Object(p), given); + } + // Wrong types and non-numbers are left for `resolve_params` to reject, never a panic. + let mut p = json!({"amount": "lots", "radius": true, "shift": [1]}).as_object().unwrap().clone(); + m.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p.clone()), json!({"amount": "lots", "radius": true, "shift": [1]})); + assert!(m.resolve_params(&Value::Object(p)).is_err()); + // A plug-in without px params is never touched, even with built-in pixel names. + let old = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","params":{"radius":{"type":"number","min":0,"max":9,"default":4}}}"#).unwrap(); + let mut p = json!({"radius": 8}).as_object().unwrap().clone(); + old.scale_for_preview(&mut p, 4); + assert_eq!(Value::Object(p), json!({"radius": 8})); + } + + #[test] + fn preview_factor_keeps_pixel_params_above_their_minimum() { + let m = Manifest::parse(PIXELS.as_bytes()).unwrap(); + let p = |v: Value| v.as_object().unwrap().clone(); + // radius min 2: 10 px allows 4, 5 px allows 2, 3 px only full resolution. + assert_eq!(m.preview_factor(&p(json!({"radius": 10})), 4), 4); + assert_eq!(m.preview_factor(&p(json!({"radius": 5})), 4), 2); + assert_eq!(m.preview_factor(&p(json!({"radius": 3})), 4), 1); + // Missing: the default (10). Params with a zero or negative minimum don't limit it. + assert_eq!(m.preview_factor(&Map::new(), 8), 5); + assert_eq!(m.preview_factor(&p(json!({"radius": 50, "amount": 0, "shift": -40})), 8), 8); + // Never more than the document's factor, never 0; bad values don't limit it. + assert_eq!(m.preview_factor(&p(json!({"radius": 50})), 2), 2); + assert_eq!(m.preview_factor(&p(json!({"radius": 50})), 0), 1); + assert_eq!(m.preview_factor(&p(json!({"radius": "x"})), 4), 4); + let unitless = Manifest::parse(FULL.as_bytes()).unwrap(); + assert_eq!(unitless.preview_factor(&p(json!({"amount": 0.01})), 4), 4); + } + fn with_name(name: &str) -> Result { Manifest::parse(json!({"id": "org.example.tone", "name": name, "kind": "filter"}).to_string().as_bytes()) } diff --git a/crates/psd/README.md b/crates/psd/README.md index d745fd5bd8..421d706ab7 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -106,6 +106,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla | PSB 8-byte-length keys | ✓ | ✓ | – | LMsk Lr16 Lr32 Layr Mt16 Mt32 Mtrn Alph FMsk lnk2 FEid FXid PxSD | | Lr16 / Lr32 / Layr layer info | ✓ | ✓ | ✓ | lifted into `layer_info`; re-inserted at its original position | | luni lsct lsdk lyid lnsr clbl infx knko lspf lclr iOpa shmd | ✓ | ✓ | ✓ | `TaggedBlock::parsed()` | +| Layer XMP (`shmd` › `cust` › `layerXMP`) | ✓ | ✓ | ✓ | `metadata::layer_xmp` / `take_layer_xmp` / `put_layer_xmp`, laid out as Photoshop 2026 writes it | | ActionDescriptor (all OSTypes) | ✓ | ✓ | ✓ | including references, `ObAr` (best effort), `UnFl`, `Pth `, version-16 wrapper | | Layer tree (groups) | ✓ | ✓ | ✓ | open/closed folders plus bounding dividers; tolerant of malformed nesting | | RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | @@ -144,7 +145,7 @@ allocations for padded channels return an error on failure. * Builder → `rgba8` correctness. * Unknown blend modes and unknown blocks pass through unchanged. -`tests/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently. +`tests/it/corpus.rs` walks `corpus/psd/**/*.{psd,psb}` at the workspace root when that directory exists. For each file it asserts that parsing succeeds and the file round-trips byte for byte, and it prints a result per file. Without the directory it skips silently. A `cargo-fuzz` skeleton lives in `fuzz/`. It is its own workspace and is excluded from photocraft's. Run it with `cargo +nightly fuzz run parse` (or `descriptor`) from `crates/psd`. diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs index 8a39bbb121..04e268da94 100644 --- a/crates/psd/examples/atn_dump.rs +++ b/crates/psd/examples/atn_dump.rs @@ -66,8 +66,8 @@ fn main() { for item in &a.items { let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" }); match &item.descriptor { - Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.dictionary, keys(d)), - None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.dictionary), + Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.label(), keys(d)), + None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.label()), } } } diff --git a/crates/psd/examples/atn_roundtrip.rs b/crates/psd/examples/atn_roundtrip.rs new file mode 100644 index 0000000000..b73151b745 --- /dev/null +++ b/crates/psd/examples/atn_roundtrip.rs @@ -0,0 +1,54 @@ +//! Check `.atn` files for byte-exact round trips: parse, write, compare, and show the first +//! difference with its context: `cargo run -p photocraft-psd --example atn_roundtrip -- set.atn…` +//! +//! Exit status 1 when any file differs or fails to parse. + +use photocraft_psd::atn; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect::>().join(" ") +} + +fn main() { + let mut failed = false; + for path in std::env::args().skip(1) { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("{path}: {e}"); + failed = true; + continue; + } + }; + let file = match atn::parse(&bytes) { + Ok(f) => f, + Err(e) => { + println!("{path}: parse failed: {e}"); + failed = true; + continue; + } + }; + let written = match atn::write(&file) { + Ok(w) => w, + Err(e) => { + println!("{path}: write failed: {e}"); + failed = true; + continue; + } + }; + if written == bytes { + println!("{path}: identical ({} bytes)", bytes.len()); + continue; + } + failed = true; + let at = bytes.iter().zip(&written).position(|(a, b)| a != b).unwrap_or(bytes.len().min(written.len())); + let lo = at.saturating_sub(24); + println!("{path}: differs at byte {at} (original {} bytes, rewritten {} bytes)", bytes.len(), written.len()); + println!(" original : {}", hex(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[]))); + println!(" rewritten: {}", hex(written.get(lo..(at + 40).min(written.len())).unwrap_or(&[]))); + println!(" original : {}", String::from_utf8_lossy(bytes.get(lo..(at + 40).min(bytes.len())).unwrap_or(&[])).replace(|c: char| c.is_control(), ".")); + } + if failed { + std::process::exit(1); + } +} diff --git a/crates/psd/examples/big_psb.rs b/crates/psd/examples/big_psb.rs index 332936c1e5..23bf4de776 100644 --- a/crates/psd/examples/big_psb.rs +++ b/crates/psd/examples/big_psb.rs @@ -1,186 +1,24 @@ -//! Writes multi-gigabyte PSB files for large-file tests (#375, #216), straight from the Adobe -//! PSD spec and independent of this crate's writer, so the fixtures don't inherit its limits. +//! Writes multi-gigabyte PSB files (and the 2 GB PSD) for large-file tests (#375, #216), straight +//! from the Adobe PSD spec and independent of this crate's writer, so the fixtures don't inherit +//! its limits. The generator is `photocraft_psd::fixtures` (feature `testgen`). //! //! `cargo run --release -p photocraft-psd --example big_psb -- [case...]` //! -//! Every sample follows [`sample`], so a reader can check any pixel without the file. RLE cases -//! keep each row constant (tiny files, full decoded size); raw cases vary along x too. +//! Every sample follows `fixtures::sample`, so a reader can check any pixel without the file. RLE +//! cases keep each row constant (tiny files, full decoded size); raw cases vary along x too. -use std::fs::File; -use std::io::{self, BufWriter, Write}; -use std::path::Path; +use photocraft_psd::fixtures::{PSB_CASES, PSD_2GB}; -/// One generated file. -struct Case { - name: &'static str, - width: u32, - height: u32, - depth: u16, - /// Raw (0) or RLE (1). - rle: bool, - /// One full-canvas RGBA layer, or a flattened file (composite only). - layered: bool, -} - -const CASES: &[Case] = &[ - // Composite of 2.52 GB in one decode. - Case { name: "flat8-29k-rle", width: 29_000, height: 29_000, depth: 8, rle: true, layered: false }, - // A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB. - Case { name: "flat8-40k-raw", width: 40_000, height: 40_000, depth: 8, rle: false, layered: false }, - // 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB. - Case { name: "layer8-25k-raw", width: 25_000, height: 25_000, depth: 8, rle: false, layered: true }, - // Layer channels of 900 MB, composite of 2.7 GB. - Case { name: "layer8-30k-rle", width: 30_000, height: 30_000, depth: 8, rle: true, layered: true }, - // Layer channels of 2.18 GB each. - Case { name: "layer16-33k-rle", width: 33_000, height: 33_000, depth: 16, rle: true, layered: true }, -]; - -/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`). RLE files ignore `x`. -/// 16-bit files store `v * 257`. -pub 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 -} - -fn main() -> io::Result<()> { +fn main() -> std::io::Result<()> { let args: Vec = std::env::args().skip(1).collect(); let out = args.first().map_or_else(|| std::env::temp_dir().join("photocraft-big-psb"), std::path::PathBuf::from); std::fs::create_dir_all(&out)?; let wanted = args.get(1..).unwrap_or(&[]); - for case in CASES.iter().filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) { - let path = out.join(format!("{}.psb", case.name)); + for case in PSB_CASES.iter().chain([&PSD_2GB]).filter(|c| wanted.is_empty() || wanted.iter().any(|w| w == c.name)) { + let path = out.join(case.file_name()); let t = std::time::Instant::now(); - write_case(case, &path)?; - let len = std::fs::metadata(&path)?.len(); + let len = case.write_file(&path)?; println!("{}: {:.2} GB in {:.1} s", path.display(), len as f64 / 1e9, t.elapsed().as_secs_f64()); } Ok(()) } - -fn row_bytes(c: &Case) -> u64 { - u64::from(c.width) * u64::from(c.depth / 8) -} - -/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder. -fn rle_row(n: u64, b: u8) -> Vec { - let mut v = Vec::new(); - let mut left = n; - while left > 0 { - let run = left.min(128); - if run == 1 { - v.extend_from_slice(&[0, b]); - } else { - v.extend_from_slice(&[(257 - run) as u8, b]); - } - left -= run; - } - v -} - -/// The bytes of one row of channel `ch`. -fn row(c: &Case, y: u32, ch: u32, buf: &mut Vec) { - buf.clear(); - if c.rle { - buf.extend(rle_row(row_bytes(c), sample(0, y, ch, true))); - return; - } - for x in 0..c.width { - let s = sample(x, y, ch, false); - buf.push(s); - if c.depth == 16 { - buf.push(s); - } - } -} - -/// Size of a channel block (compression word, RLE counts, rows) of `planes` planes. -fn channel_len(c: &Case, planes: u64) -> u64 { - let rows = planes * u64::from(c.height); - if c.rle { 2 + rows * 4 + rows * rle_row(row_bytes(c), 0).len() as u64 } else { 2 + rows * row_bytes(c) } -} - -/// Writes the compression word, the counts and the rows of `chans`. -fn write_planes(w: &mut impl Write, c: &Case, chans: &[u32]) -> io::Result<()> { - w.write_all(&u16::from(c.rle).to_be_bytes())?; - if c.rle { - let n = rle_row(row_bytes(c), 0).len() as u32; - for _ in 0..chans.len() as u64 * u64::from(c.height) { - w.write_all(&n.to_be_bytes())?; - } - } - let mut buf = Vec::new(); - for &ch in chans { - for y in 0..c.height { - row(c, y, ch, &mut buf); - w.write_all(&buf)?; - } - } - Ok(()) -} - -fn write_case(c: &Case, path: &Path) -> io::Result<()> { - let mut w = BufWriter::with_capacity(8 << 20, File::create(path)?); - // Header: PSB (version 2), 3 channels, RGB. - w.write_all(b"8BPS")?; - w.write_all(&2u16.to_be_bytes())?; - w.write_all(&[0; 6])?; - w.write_all(&3u16.to_be_bytes())?; - w.write_all(&c.height.to_be_bytes())?; - w.write_all(&c.width.to_be_bytes())?; - w.write_all(&c.depth.to_be_bytes())?; - w.write_all(&3u16.to_be_bytes())?; - // Colour mode data and image resources: empty. - w.write_all(&0u32.to_be_bytes())?; - w.write_all(&0u32.to_be_bytes())?; - - if c.layered { - // Transparency (-1), then R, G, B: one block per channel. - let ids: [(i16, u32); 4] = [(-1, 3), (0, 0), (1, 1), (2, 2)]; - let ch_len = channel_len(c, 1); - let name = b"Layer 1"; - let mut rec = Vec::new(); - for v in [0i32, 0, c.height as i32, c.width as i32] { - rec.extend_from_slice(&v.to_be_bytes()); - } - rec.extend_from_slice(&4u16.to_be_bytes()); - for (id, _) in ids { - rec.extend_from_slice(&id.to_be_bytes()); - rec.extend_from_slice(&ch_len.to_be_bytes()); - } - rec.extend_from_slice(b"8BIMnorm"); - rec.extend_from_slice(&[255, 0, 0, 0]); - // Extra data: no mask, no blending ranges, the Pascal name padded to 4. - let mut extra = Vec::new(); - extra.extend_from_slice(&0u32.to_be_bytes()); - extra.extend_from_slice(&0u32.to_be_bytes()); - extra.push(name.len() as u8); - extra.extend_from_slice(name); - while extra.len() % 4 != 0 { - extra.push(0); - } - rec.extend_from_slice(&(extra.len() as u32).to_be_bytes()); - rec.extend_from_slice(&extra); - - let mut layer_info = 2 + rec.len() as u64 + 4 * ch_len; - let pad = layer_info % 2; - layer_info += pad; - // Layer and mask information: layer info (8-byte length) + empty global mask (4 bytes). - w.write_all(&(8 + layer_info + 4).to_be_bytes())?; - w.write_all(&layer_info.to_be_bytes())?; - w.write_all(&1i16.to_be_bytes())?; - w.write_all(&rec)?; - for (_, ch) in ids { - write_planes(&mut w, c, &[ch])?; - } - w.write_all(&vec![0; pad as usize])?; - w.write_all(&0u32.to_be_bytes())?; - } else { - w.write_all(&0u64.to_be_bytes())?; - } - // The merged image. - write_planes(&mut w, c, &[0, 1, 2])?; - w.flush() -} diff --git a/crates/psd/fuzz/fuzz_targets/grd.rs b/crates/psd/fuzz/fuzz_targets/grd.rs index 2779a96846..16ab26a137 100644 --- a/crates/psd/fuzz/fuzz_targets/grd.rs +++ b/crates/psd/fuzz/fuzz_targets/grd.rs @@ -3,7 +3,7 @@ //! //! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len` //! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with -//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`. +//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/it/swatch_malformed.rs`. #![no_main] use libfuzzer_sys::fuzz_target; use photocraft_psd::grd::{self, GrdColor}; diff --git a/crates/psd/src/aco.rs b/crates/psd/src/aco.rs index 11ff653caf..24f0b1c6f1 100644 --- a/crates/psd/src/aco.rs +++ b/crates/psd/src/aco.rs @@ -166,6 +166,36 @@ pub fn parse(data: &[u8]) -> Result { } } +/// How many bytes the `.aco` sections at the start of `data` span (what [`parse`] reads), or +/// `None` when they are truncated or the version is unknown. Tells a real `.aco` apart from +/// bytes that only start like one, such as an `.act` whose first colour is `0, 1, x`. +pub fn sections_len(data: &[u8]) -> Option { + fn skip_section(r: &mut Reader<'_>, named: bool) -> Result<()> { + let count = r.u16()?; + for _ in 0..count { + r.skip(10)?; + if named { + let n = u64::from(r.u32()?); + r.bytes_u64(n * 2)?; + } + } + Ok(()) + } + let mut r = Reader::new(data); + match r.u16().ok()? { + 1 => { + skip_section(&mut r, false).ok()?; + if r.remaining() >= 4 && r.peek_rest().get(..2) == Some(&[0, 2]) { + r.u16().ok()?; + skip_section(&mut r, true).ok()?; + } + } + 2 => skip_section(&mut r, true).ok()?, + _ => return None, + } + Some(r.pos()) +} + /// Write an `.aco` file: a version 1 section followed by a version 2 section with the names, /// as Photoshop writes them. Fails when there are more than [`MAX_SWATCHES`] swatches. pub fn write(swatches: &[AcoSwatch]) -> Result> { @@ -210,6 +240,18 @@ mod tests { ] } + #[test] + fn sections_len_is_what_parse_reads() { + let file = write(&sample()).unwrap(); + assert_eq!(sections_len(&file), Some(file.len())); + let mut padded = file.clone(); + padded.extend_from_slice(&[0; 6]); + assert_eq!(sections_len(&padded), Some(file.len()), "trailing bytes are not part of it"); + assert_eq!(sections_len(&file[..file.len() - 1]), None, "truncated"); + assert_eq!(sections_len(&[0, 3, 0, 0]), None, "unknown version"); + assert_eq!(sections_len(&[0, 1, 0, 0]), Some(4)); + } + #[test] fn round_trips_every_space_at_16_bits() { let s = sample(); diff --git a/crates/psd/src/act.rs b/crates/psd/src/act.rs new file mode 100644 index 0000000000..58e872bf97 --- /dev/null +++ b/crates/psd/src/act.rs @@ -0,0 +1,143 @@ +//! Photoshop Color Table files (`.act`): raw 8-bit RGB palettes. +//! +//! Layout from Adobe's public "Photoshop File Formats Specification" (Color Table): 256 RGB +//! triplets (768 bytes), optionally followed by a 4-byte trailer: a big-endian `u16` color +//! count ("the number of colors to use") and a big-endian `u16` transparent color index. +//! There are no names, no groups and no other color spaces. The trailer is kept verbatim so a +//! read → write round trip never loses it; interpreting it is the caller's job. + +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// Colors in a `.act` palette. +pub const COLOR_COUNT: usize = 256; +/// Palette bytes: 256 RGB triplets. +pub const PALETTE_LEN: usize = COLOR_COUNT * 3; +/// Trailer bytes (color count + transparent index). +pub const TRAILER_LEN: usize = 4; + +/// A parsed `.act` file: the full 256-color palette plus the optional trailer, kept verbatim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActFile { + /// All 256 palette colors as RGB triplets. + pub colors: [[u8; 3]; COLOR_COUNT], + /// Trailer color count ("the number of colors to use"), if the file has one. + pub count: Option, + /// Trailer transparent color index, if the file has one. + pub transparent: Option, +} + +impl ActFile { + /// Colors to use: the trailer count clamped to the palette, else the whole palette. + pub fn used(&self) -> &[[u8; 3]] { + let n = self.count.map_or(COLOR_COUNT, |c| (c as usize).min(COLOR_COUNT)); + &self.colors[..n] + } + + /// The transparent color index, if the trailer names one inside the used colors. + pub fn transparent_index(&self) -> Option { + let t = usize::from(self.transparent?); + (t < self.used().len()).then_some(t) + } +} + +/// Parse an `.act` file. Anything but 768 bytes (palette only) or 772 bytes (plus the +/// trailer) is an error; never panics. +pub fn parse(data: &[u8]) -> Result { + if data.len() != PALETTE_LEN && data.len() != PALETTE_LEN + TRAILER_LEN { + return Err(PsdError::invalid("an .act file is 768 bytes, or 772 with the count trailer")); + } + let mut r = Reader::new(data); + let raw = r.bytes(PALETTE_LEN)?; + let mut colors = [[0u8; 3]; COLOR_COUNT]; + for (i, c) in colors.iter_mut().enumerate() { + let o = i * 3; + c.copy_from_slice(&raw[o..o + 3]); + } + let (count, transparent) = if r.remaining() >= TRAILER_LEN { (Some(r.u16()?), Some(r.u16()?)) } else { (None, None) }; + Ok(ActFile { colors, count, transparent }) +} + +/// Write an `.act` file: the 256-color palette plus the trailer when `count` or `transparent` is given. +/// Short palettes are padded with black; fails on a palette longer than 256 colors. +pub fn write(colors: &[[u8; 3]], count: Option, transparent: Option) -> Result> { + if colors.len() > COLOR_COUNT { + return Err(PsdError::LimitExceeded("an .act file holds at most 256 colors")); + } + let mut out = Vec::with_capacity(PALETTE_LEN + TRAILER_LEN); + for c in colors { + out.extend_from_slice(c); + } + out.resize(PALETTE_LEN, 0); + if count.is_some() || transparent.is_some() { + out.put_u16(count.unwrap_or(COLOR_COUNT as u16)); + out.put_u16(transparent.unwrap_or(0xFFFF)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> [[u8; 3]; COLOR_COUNT] { + let mut colors = [[0u8; 3]; COLOR_COUNT]; + for (i, c) in colors.iter_mut().enumerate() { + let v = i as u8; + *c = [v, v.wrapping_mul(7), v.wrapping_add(13)]; + } + colors[0] = [255, 255, 255]; + colors + } + + #[test] + fn round_trips_with_and_without_the_trailer() { + let colors = sample(); + let bare = write(&colors, None, None).unwrap(); + assert_eq!(bare.len(), PALETTE_LEN); + let f = parse(&bare).unwrap(); + assert_eq!((f.count, f.transparent), (None, None)); + assert_eq!(f.used(), &colors); + assert_eq!(f.transparent_index(), None); + assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), bare, "byte-stable"); + + let trailer = write(&colors, Some(40), Some(7)).unwrap(); + assert_eq!(trailer.len(), PALETTE_LEN + TRAILER_LEN); + let f = parse(&trailer).unwrap(); + assert_eq!((f.count, f.transparent), (Some(40), Some(7))); + assert_eq!(f.used().len(), 40); + assert_eq!(f.transparent_index(), Some(7)); + assert_eq!(write(&f.colors, f.count, f.transparent).unwrap(), trailer, "byte-stable"); + } + + #[test] + fn short_palettes_pad_and_long_ones_fail() { + let few = vec![[1u8, 2, 3]; 3]; + let bytes = write(&few, None, None).unwrap(); + assert_eq!(bytes.len(), PALETTE_LEN); + assert_eq!(&bytes[..9], &[1u8, 2, 3, 1, 2, 3, 1, 2, 3]); + assert_eq!(&bytes[9..12], &[0u8, 0, 0]); + let many = vec![[0u8; 3]; COLOR_COUNT + 1]; + assert!(write(&many, None, None).is_err()); + } + + #[test] + fn hostile_lengths_are_errors_not_panics() { + let good = write(&sample(), Some(256), Some(255)).unwrap(); + assert_eq!(good.len(), PALETTE_LEN + TRAILER_LEN); + for n in 0..good.len() { + // Only the two exact lengths parse (768 reads as a trailer-less palette). + if n == PALETTE_LEN { + assert!(parse(&good[..n]).is_ok(), "{n}"); + } else { + assert!(parse(&good[..n]).is_err(), "{n}"); + } + } + assert!(parse(&[]).is_err()); + assert!(parse(&vec![0u8; PALETTE_LEN + TRAILER_LEN + 1]).is_err(), "trailing byte"); + // A count past the palette clamps; a transparent index past it is none. + let f = parse(&write(&sample(), Some(1000), Some(999)).unwrap()).unwrap(); + assert_eq!(f.used().len(), COLOR_COUNT); + assert_eq!(f.transparent_index(), None); + } +} diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 2d8bf9990f..6bb900d435 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -17,8 +17,10 @@ //! //! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the //! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events -//! carry them. Photoshop 5-era files (version 12) use a different, undocumented layout and are -//! rejected as unsupported. +//! carry them. The event name and the panel name are byte strings (ASCII, or the platform's +//! legacy encoding for a localised panel name: Mac Roman "Définir"), kept verbatim; +//! [`AtnItem::label`] gives a readable name. Photoshop 5-era files (version 12) use a +//! different, undocumented layout and are rejected as unsupported. //! //! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor //! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so @@ -26,7 +28,7 @@ use crate::descriptor::{Descriptor, UnicodeString}; use crate::error::{PsdError, Result}; -use crate::io::{Reader, WriteExt}; +use crate::io::{Reader, WriteExt, decode_legacy_name}; /// The only version this module reads and writes (Photoshop 6 and every later release). pub const VERSION: u32 = 16; @@ -84,8 +86,9 @@ pub struct AtnItem { pub dialog_options: u8, /// The event. pub event: AtnEvent, - /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …). - pub dictionary: String, + /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …), as stored: + /// ASCII, or the authoring platform's legacy encoding when localised. See [`Self::label`]. + pub dictionary: Vec, /// The event's parameters; `None` for events without any. pub descriptor: Option, } @@ -98,49 +101,71 @@ impl Default for AtnItem { with_dialog: false, dialog_options: 0, event: AtnEvent::Code(*b" "), - dictionary: String::new(), + dictionary: Vec::new(), descriptor: None, } } } +impl AtnItem { + /// The panel name as text: UTF-8 when it is, otherwise decoded byte by byte (Latin-1). + pub fn dictionary(&self) -> String { + decode_legacy_name(&self.dictionary) + } + + /// A readable name for the step: the descriptor's class name when Photoshop stored one + /// (Unicode, so a localised name survives), else [`Self::dictionary`], else the event id. + pub fn label(&self) -> String { + let class = self.descriptor.as_ref().map(|d| d.name.to_string_lossy()).unwrap_or_default(); + if !class.trim().is_empty() { + return class; + } + let dict = self.dictionary(); + if dict.trim().is_empty() { self.event.id() } else { dict } + } +} + /// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`). #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum AtnEvent { /// Stored as `long` + 4 bytes. Code([u8; 4]), - /// Stored as `TEXT` + length-prefixed ASCII. - Name(String), + /// Stored as `TEXT` + length-prefixed bytes (ASCII in every file seen). + Name(Vec), } impl AtnEvent { + /// A string-named event. + pub fn name(s: &str) -> Self { + AtnEvent::Name(s.as_bytes().to_vec()) + } /// The event id as text: the code's four characters, or the name. pub fn id(&self) -> String { match self { AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(), - AtnEvent::Name(n) => n.clone(), + AtnEvent::Name(n) => String::from_utf8_lossy(n).into_owned(), } } /// `true` if the event is the code or name `s`. pub fn is(&self, s: &str) -> bool { match self { AtnEvent::Code(c) => c == s.as_bytes(), - AtnEvent::Name(n) => n == s, + AtnEvent::Name(n) => n == s.as_bytes(), } } } -/// Reads a u32 length + ASCII bytes (event and dictionary names). -fn read_ascii(r: &mut Reader<'_>) -> Result { +/// Reads a u32 length + bytes (event and dictionary names), verbatim. +fn read_ascii(r: &mut Reader<'_>) -> Result> { let n = r.u32()?; - Ok(String::from_utf8_lossy(r.bytes_u64(u64::from(n))?).into_owned()) + Ok(r.bytes_u64(u64::from(n))?.to_vec()) } -fn write_ascii(out: &mut Vec, s: &str) -> Result<()> { +fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?; out.put_u32(n); - out.put(s.as_bytes()); + out.put(s); Ok(()) } @@ -277,12 +302,12 @@ mod tests { with_dialog: true, dialog_options: 2, event: AtnEvent::Code(*b"GsnB"), - dictionary: "Gaussian Blur".into(), + dictionary: b"Gaussian Blur".to_vec(), descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))), }; let trim = AtnItem { - event: AtnEvent::Name("trim".into()), - dictionary: "Trim".into(), + event: AtnEvent::name("trim"), + dictionary: b"Trim".to_vec(), descriptor: Some( Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() } .with( @@ -293,7 +318,7 @@ mod tests { ), ..Default::default() }; - let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: "Flatten Image".into(), ..Default::default() }; + let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: b"Flatten Image".to_vec(), ..Default::default() }; AtnFile { name: UnicodeString::new_nul("Set ✓"), expanded: true, @@ -324,6 +349,21 @@ mod tests { assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB"); assert!(parsed.actions[1].items[0].event.is("trim")); assert_eq!(parsed.actions[1].items[0].event.id(), "trim"); + assert_eq!(parsed.actions[0].items[0].label(), "Gaussian Blur"); + assert_eq!(parsed.actions[1].items[0].label(), "Trim"); + // A localised panel name in the platform's legacy encoding survives a round trip and + // the Unicode class name is the readable label. + let localised = AtnItem { + event: AtnEvent::name("set"), + dictionary: b"D\x8efinir".to_vec(), + descriptor: Some(Descriptor { name: UnicodeString::new_nul("Définir"), class_id: crate::descriptor::Id::new("setd"), items: Vec::new() }), + ..Default::default() + }; + assert_eq!(localised.label(), "Définir"); + let f = AtnFile { actions: vec![AtnAction { items: vec![localised.clone()], ..Default::default() }], ..Default::default() }; + let bytes = write(&f).unwrap(); + assert_eq!(parse(&bytes).unwrap().actions[0].items[0], localised); + assert_eq!(AtnItem { event: AtnEvent::name("x"), ..Default::default() }.label(), "x"); } /// The layout Photoshop writes for a one-step Trim action, assembled by hand from the @@ -338,8 +378,8 @@ mod tests { expanded: true, items: vec![AtnItem { expanded: true, - event: AtnEvent::Name("trim".into()), - dictionary: "Trim".into(), + event: AtnEvent::name("trim"), + dictionary: b"Trim".to_vec(), descriptor: Some(Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), diff --git a/crates/psd/src/filter_effects.rs b/crates/psd/src/filter_effects.rs index e8b5efb698..cadb3ac8d0 100644 --- a/crates/psd/src/filter_effects.rs +++ b/crates/psd/src/filter_effects.rs @@ -170,7 +170,10 @@ impl FilterEffects { let pad = ((4 - len % 4) % 4).min(r.remaining() as u64); r.skip(pad as usize)?; let rest = r.peek_rest(); - if rest.iter().all(|&b| b == 0) && rest.len() <= 3 { + // Padding is at most three bytes. Check the length before scanning the suffix for + // zeros: otherwise each iteration scans the whole remaining buffer, which is + // quadratic when a block contains many empty sections. + if rest.len() <= 3 && rest.iter().all(|&b| b == 0) { break; } if rest.len() < 8 { diff --git a/crates/psd/src/fixtures.rs b/crates/psd/src/fixtures.rs new file mode 100644 index 0000000000..a8bd3d43c9 --- /dev/null +++ b/crates/psd/src/fixtures.rs @@ -0,0 +1,566 @@ +//! Large-file stress fixtures (#216, #375, #5107), generated at test time and never committed. +//! +//! Two kinds: +//! +//! * [`Streamed`]: multi-gigabyte PSD/PSB files written straight from the Adobe PSD spec, row by +//! row, independent of [`PsdBuilder`] (so they don't inherit its limits, and the generator needs +//! a few megabytes of memory however large the file). One full-canvas layer (or none) plus the +//! merged image; every sample follows [`sample`], so a reader can check any pixel without the +//! file. [`PSD_2GB`] is the largest file Photoshop saves as PSD; [`PSB_CASES`] are 2.5–5 GB. +//! * [`Grid`]: layered documents built with [`PsdBuilder`]: a background and a grid of layers in +//! groups, with masks, a hidden layer, a closed group and drop shadows. [`LAYERED_20MP`] is the +//! 20 MP photo-sized document, [`LAYOUT_150`] the 150-layer A4 layout. [`Grid::expected`] is +//! the merged colour at any point away from the shadows ([`Grid::sample_points`] lists some). +//! +//! Photoshop's limits, which the presets respect: a PSD is at most 30,000 px a side and 2 GB; a +//! PSB at most 300,000 px a side. + +use std::io::{self, Write}; + +use crate::builder::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder}; +use crate::descriptor::{Descriptor, Id, Value, VersionedDescriptor}; +use crate::error::Result; +use crate::file::PsdFile; +use crate::header::Version; +use crate::layer::Rect; +use crate::tagged::TaggedBlock; + +/// Photoshop's PSD size cap: files of 2 GB or more must be PSB. +pub const PSD_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Photoshop's PSD side cap (px). +pub const PSD_MAX_SIDE: u32 = 30_000; + +/// The 8-bit sample of channel `c` (0..3 colour, 3 = alpha) at (`x`, `y`) in a [`Streamed`] file. +/// RLE files ignore `x` (constant rows compress to almost nothing). 16-bit files store `v * 257`. +pub 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 +} + +/// A large RGB file written row by row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Streamed { + /// File stem, e.g. `layer8-25k-raw`. + pub name: &'static str, + /// PSD (v1) or PSB (v2). + pub version: Version, + /// Width (px). + pub width: u32, + /// Height (px). + pub height: u32, + /// 8 or 16. + pub depth: u16, + /// RLE (PackBits) or raw. + pub rle: bool, + /// One full-canvas RGBA layer, or a flattened file (merged image only). + pub layered: bool, +} + +const fn psb(name: &'static str, side: u32, depth: u16, rle: bool, layered: bool) -> Streamed { + Streamed { name, version: Version::Psb, width: side, height: side, depth, rle, layered } +} + +/// A 2.02 GB layered PSD just under Photoshop's 2 GB cap: 17,000² px, one raw full-canvas layer +/// (289 MB per channel) and the raw merged image. +pub const PSD_2GB: Streamed = + Streamed { name: "psd-layer8-17k-raw", version: Version::Psd, width: 17_000, height: 17_000, depth: 8, rle: false, layered: true }; + +/// The 4–5 GB PSB generated for #375 and #216. +pub const PSB_CASES: [Streamed; 5] = [ + // Composite of 2.52 GB in one decode. + psb("flat8-29k-rle", 29_000, 8, true, false), + // A 4.8 GB file: offsets past 4 GiB, composite of 4.8 GB. + psb("flat8-40k-raw", 40_000, 8, false, false), + // 4.38 GB file: layer channels of 625 MB, composite of 1.9 GB, data past 4 GiB. + PSB_4GB, + // Layer channels of 900 MB, composite of 2.7 GB. + psb("layer8-30k-rle", 30_000, 8, true, true), + // Layer channels of 2.18 GB each. + psb("layer16-33k-rle", 33_000, 16, true, true), +]; + +/// The layered 4.38 GB PSB (25,000² px, raw): layer data and the merged image past 4 GiB. +pub const PSB_4GB: Streamed = psb("layer8-25k-raw", 25_000, 8, false, true); + +/// PackBits for a row of `n` equal bytes: runs of 128, then the remainder. +fn rle_row(n: u64, b: u8) -> Vec { + let mut v = Vec::new(); + let mut left = n; + while left > 0 { + let run = left.min(128); + // `run` is 1..=128, so 257 - run is 129..=256 and only 256 (run 1) needs the literal form. + if run == 1 { + v.extend_from_slice(&[0, b]); + } else { + v.extend_from_slice(&[(257 - run) as u8, b]); + } + left -= run; + } + v +} + +fn too_big(what: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, format!("{what} does not fit a PSD (v1) length field")) +} + +impl Streamed { + /// File name with the right extension. + pub fn file_name(&self) -> String { + format!("{}.{}", self.name, if self.version == Version::Psb { "psb" } else { "psd" }) + } + + fn psb(&self) -> bool { + self.version == Version::Psb + } + + fn row_bytes(&self) -> u64 { + u64::from(self.width) * u64::from(self.depth / 8) + } + + /// Bytes of one RLE row (all rows have the same length). + fn rle_len(&self) -> u64 { + rle_row(self.row_bytes(), 0).len() as u64 + } + + /// Size of a channel block (compression word, RLE counts, rows) of `planes` planes. + fn channel_len(&self, planes: u64) -> u64 { + let rows = planes * u64::from(self.height); + let count = if self.psb() { 4 } else { 2 }; + if self.rle { 2 + rows * count + rows * self.rle_len() } else { 2 + rows * self.row_bytes() } + } + + /// Writes a length field: 8 bytes in PSB, 4 in PSD. + fn put_len(&self, out: &mut Vec, v: u64, what: &str) -> io::Result<()> { + if self.psb() { + out.extend_from_slice(&v.to_be_bytes()); + } else { + out.extend_from_slice(&u32::try_from(v).map_err(|_| too_big(what))?.to_be_bytes()); + } + Ok(()) + } + + /// The layer record of the one layer: transparency (-1), then R, G, B. + fn layer_record(&self) -> io::Result> { + let ch_len = self.channel_len(1); + let name = b"Layer 1"; + let mut rec = Vec::new(); + for v in [0, 0, self.height, self.width] { + rec.extend_from_slice(&v.to_be_bytes()); + } + rec.extend_from_slice(&4u16.to_be_bytes()); + for id in [-1i16, 0, 1, 2] { + rec.extend_from_slice(&id.to_be_bytes()); + self.put_len(&mut rec, ch_len, "a layer channel")?; + } + rec.extend_from_slice(b"8BIMnorm"); + rec.extend_from_slice(&[255, 0, 0, 0]); + // Extra data: no mask, no blending ranges, the Pascal name padded to 4. + let mut extra = Vec::new(); + extra.extend_from_slice(&0u32.to_be_bytes()); + extra.extend_from_slice(&0u32.to_be_bytes()); + extra.push(name.len() as u8); + extra.extend_from_slice(name); + while extra.len() % 4 != 0 { + extra.push(0); + } + rec.extend_from_slice(&(extra.len() as u32).to_be_bytes()); + rec.extend_from_slice(&extra); + Ok(rec) + } + + /// The header, the empty colour-mode and resource sections and the layer section's head + /// (everything before the first channel's data), plus the padding after the layer channels. + fn head(&self) -> io::Result<(Vec, u64)> { + let mut h = Vec::new(); + h.extend_from_slice(b"8BPS"); + h.extend_from_slice(&(if self.psb() { 2u16 } else { 1 }).to_be_bytes()); + h.extend_from_slice(&[0; 6]); + h.extend_from_slice(&3u16.to_be_bytes()); + h.extend_from_slice(&self.height.to_be_bytes()); + h.extend_from_slice(&self.width.to_be_bytes()); + h.extend_from_slice(&self.depth.to_be_bytes()); + h.extend_from_slice(&3u16.to_be_bytes()); + // Colour mode data and image resources: empty. + h.extend_from_slice(&0u32.to_be_bytes()); + h.extend_from_slice(&0u32.to_be_bytes()); + if !self.layered { + self.put_len(&mut h, 0, "the layer section")?; + return Ok((h, 0)); + } + let rec = self.layer_record()?; + let mut layer_info = 2 + rec.len() as u64 + 4 * self.channel_len(1); + let pad = layer_info % 2; + layer_info += pad; + let len_field = if self.psb() { 8 } else { 4 }; + // Layer and mask information: layer info (with its length) + empty global mask (4 bytes). + self.put_len(&mut h, len_field + layer_info + 4, "the layer section")?; + self.put_len(&mut h, layer_info, "the layer info")?; + h.extend_from_slice(&1i16.to_be_bytes()); + h.extend_from_slice(&rec); + Ok((h, pad)) + } + + /// The exact size of the file [`write_to`](Self::write_to) writes, without writing it. + pub fn file_len(&self) -> io::Result { + let (head, pad) = self.head()?; + let layers = if self.layered { 4 * self.channel_len(1) + pad + 4 } else { 0 }; + Ok(head.len() as u64 + layers + self.channel_len(3)) + } + + /// The bytes of one row of channel `ch`. + fn row(&self, y: u32, ch: u32, buf: &mut Vec) { + buf.clear(); + if self.rle { + buf.extend(rle_row(self.row_bytes(), sample(0, y, ch, true))); + return; + } + for x in 0..self.width { + let s = sample(x, y, ch, false); + buf.push(s); + if self.depth == 16 { + buf.push(s); + } + } + } + + /// Writes the compression word, the counts and the rows of `chans`. + fn write_planes(&self, w: &mut impl Write, chans: &[u32]) -> io::Result<()> { + w.write_all(&u16::from(self.rle).to_be_bytes())?; + if self.rle { + let n = self.rle_len(); + let one = if self.psb() { + u32::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec() + } else { + u16::try_from(n).map_err(|_| too_big("an RLE row"))?.to_be_bytes().to_vec() + }; + for _ in 0..chans.len() as u64 * u64::from(self.height) { + w.write_all(&one)?; + } + } + let mut buf = Vec::new(); + for &ch in chans { + for y in 0..self.height { + self.row(y, ch, &mut buf); + w.write_all(&buf)?; + } + } + Ok(()) + } + + /// Streams the file into `w` (buffer it). Returns the bytes written. A PSD whose lengths + /// don't fit its 32-bit fields is refused before anything is written. + pub fn write_to(&self, w: &mut impl Write) -> io::Result { + let len = self.file_len()?; + let (head, pad) = self.head()?; + w.write_all(&head)?; + if self.layered { + // Transparency, then R, G, B. + for ch in [3, 0, 1, 2] { + self.write_planes(w, &[ch])?; + } + w.write_all(&vec![0; pad as usize])?; + w.write_all(&0u32.to_be_bytes())?; + } + self.write_planes(w, &[0, 1, 2])?; + w.flush()?; + Ok(len) + } + + /// Writes the file to `path` (created or truncated). Returns its size. + #[cfg(not(target_arch = "wasm32"))] + pub fn write_file(&self, path: &std::path::Path) -> io::Result { + let mut w = io::BufWriter::with_capacity(8 << 20, std::fs::File::create(path)?); + self.write_to(&mut w) + } + + /// The 8-bit value of channel `c` (3 = alpha) at (`x`, `y`): the layer's and the merged + /// image's (they are the same). + pub fn sample(&self, x: u32, y: u32, c: u32) -> u8 { + sample(x, y, c, self.rle) + } +} + +/// A layered document: a background and a grid of layers, one group per grid row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Grid { + /// Canvas width (px). + pub width: u32, + /// Canvas height (px). + pub height: u32, + /// Layers in the grid (the background is extra). + pub layers: u32, + /// Grid columns; a group per row of `cols` layers. + pub cols: u32, +} + +/// A 20 MP (5472 × 3648) layered photo: background, 5 layers in 2 groups (one closed), a +/// mask and a drop shadow. +pub const LAYERED_20MP: Grid = Grid { width: 5472, height: 3648, layers: 5, cols: 3 }; + +/// A 150-layer A4 layout at 300 ppi (2480 × 3508): background + 149 layers in 15 groups, with +/// masks, hidden layers, closed groups and drop shadows. +pub const LAYOUT_150: Grid = Grid { width: 2480, height: 3508, layers: 149, cols: 10 }; + +/// What a grid layer carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GridLayer { + /// Bounds in document coordinates. + pub rect: Rect, + /// Base colour; the layer adds its local x to it (a horizontal ramp). + pub base: [u8; 3], + /// Has a mask showing only the left half. + pub masked: bool, + /// Hidden. + pub hidden: bool, + /// Has a drop shadow (`lfx2`). + pub shadow: bool, +} + +/// The `lfx2` descriptor of a drop shadow: multiply, black, 75 %, 120°, distance 5, size 5. +fn drop_shadow() -> Vec { + let px = |v: f64| Value::UnitFloat { unit: *b"#Pxl", value: v }; + let color = Descriptor::new("RGBC").with("Rd ", Value::Double(0.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0)); + let shadow = Descriptor::new("DrSh") + .with("enab", Value::Boolean(true)) + .with("Md ", Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new("Mltp") }) + .with("Clr ", Value::Descriptor(color)) + .with("Opct", Value::UnitFloat { unit: *b"#Prc", value: 75.0 }) + .with("uglg", Value::Boolean(false)) + .with("lagl", Value::UnitFloat { unit: *b"#Ang", value: 120.0 }) + .with("Dstn", px(5.0)) + .with("Ckmt", px(0.0)) + .with("blur", px(5.0)); + let root = Descriptor::new("null") + .with("Scl ", Value::UnitFloat { unit: *b"#Prc", value: 100.0 }) + .with("masterFXSwitch", Value::Boolean(true)) + .with("DrSh", Value::Descriptor(shadow)); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor::new(root).to_bytes()); + data +} + +impl Grid { + /// Grid rows (= groups). + pub fn rows(&self) -> u32 { + self.layers.div_ceil(self.cols.max(1)) + } + + fn cell(&self) -> (u32, u32) { + (self.width / self.cols.max(1), self.height / self.rows().max(1)) + } + + /// Gap between a layer and its cell's edge (larger than any shadow's reach). + fn inset(&self) -> u32 { + let (cw, ch) = self.cell(); + (cw.min(ch) / 8).max(1) + } + + /// Layer `i` of the grid (0-based, bottom to top), or `None` past the end. + pub fn layer(&self, i: u32) -> Option { + if i >= self.layers { + return None; + } + let (cw, ch) = self.cell(); + let inset = self.inset(); + let (col, row) = (i % self.cols.max(1), i / self.cols.max(1)); + let rect = + Rect::from_xywh((col * cw + inset) as i32, (row * ch + inset) as i32, cw.saturating_sub(2 * inset).max(1), ch.saturating_sub(2 * inset).max(1)); + let masked = i % 5 == 1; + let hidden = i % 13 == 12; + Some(GridLayer { + rect, + base: [(i * 37 + 20) as u8, (i * 91 + 50) as u8, (i * 53 + 90) as u8], + masked, + hidden, + shadow: i % 7 == 3 && !masked && !hidden, + }) + } + + /// Name of grid layer `i`. + pub fn layer_name(i: u32) -> String { + format!("Layer {}", i + 1) + } + + /// Name of the group of grid row `r`. + pub fn group_name(r: u32) -> String { + format!("Row {}", r + 1) + } + + /// Groups of rows `r % 4 == 3` are closed in the layers panel. + pub fn group_open(r: u32) -> bool { + r % 4 != 3 + } + + /// The background's colour at (`x`, `y`): [`sample`] in runs of 16 px (RLE has runs to find). + pub fn background(x: u32, y: u32) -> [u8; 3] { + [0, 1, 2].map(|c| sample(x / 16, y, c, false)) + } + + /// Grid layer `i`'s pixel colour at document (`x`, `y`), ignoring visibility and mask. + fn layer_color(l: &GridLayer, x: u32) -> [u8; 3] { + let lx = x.wrapping_sub(l.rect.left as u32); + l.base.map(|b| (u32::from(b) + lx) as u8) + } + + /// The merged colour at (`x`, `y`). Exact except within a few pixels of a shadowed layer's + /// edges (the merged image this generator stores leaves shadows out). + pub fn expected(&self, x: u32, y: u32) -> [u8; 3] { + let (cw, ch) = self.cell(); + let (col, row) = (x / cw.max(1), y / ch.max(1)); + if col < self.cols + && let Some(l) = self.layer(row * self.cols + col) + { + let (xi, yi) = (x as i32, y as i32); + let inside = xi >= l.rect.left && xi < l.rect.right && yi >= l.rect.top && yi < l.rect.bottom; + let shown = !l.masked || i64::from(xi) < i64::from(l.rect.left) + l.rect.width() / 2; + if inside && shown && !l.hidden { + return Self::layer_color(&l, x); + } + } + Self::background(x, y) + } + + /// Points to check, away from every shadow: in each of a few layers, its shown half, its + /// masked-out half, and the gap at its cell's corner. + pub fn sample_points(&self) -> Vec<(u32, u32)> { + let step = (self.layers / 12).max(1); + let mut v = vec![(0, 0), (self.width - 1, self.height - 1)]; + for i in (0..self.layers).step_by(step as usize) { + let Some(l) = self.layer(i) else { continue }; + let (w, h) = (l.rect.width() as u32, l.rect.height() as u32); + let (x0, y0) = (l.rect.left as u32, l.rect.top as u32); + v.push((x0 + w / 4, y0 + h / 2)); + v.push((x0 + w * 3 / 4, y0 + h / 2)); + v.push((x0 - self.inset() / 2, y0 - self.inset() / 2)); + } + v + } + + /// Builds the file (8-bit RGB, RLE): layers bottom to top, the merged image from + /// [`expected`](Self::expected). + pub fn build(&self, version: Version) -> Result { + let (w, h) = (self.width, self.height); + let mut b = PsdBuilder::new(w, h).version(version); + let mut bg = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + for x in 0..w { + bg.extend(Self::background(x, y)); + bg.push(255); + } + } + b.push_layer(LayerSpec::new("Background", 0, 0, w, h, PixelData::Rgba8(bg))); + for row in 0..self.rows() { + b.begin_group(GroupSpec { open: Self::group_open(row), ..GroupSpec::new(Self::group_name(row)) }); + for i in row * self.cols..((row + 1) * self.cols).min(self.layers) { + let Some(l) = self.layer(i) else { continue }; + let (lw, lh) = (l.rect.width() as u32, l.rect.height() as u32); + let mut px = Vec::with_capacity(lw as usize * lh as usize * 4); + for _ in 0..lh { + for lx in 0..lw { + px.extend(Self::layer_color(&l, l.rect.left as u32 + lx)); + px.push(255); + } + } + let mut spec = LayerSpec::new(Self::layer_name(i), l.rect.left, l.rect.top, lw, lh, PixelData::Rgba8(px)); + spec.visible = !l.hidden; + if l.masked { + let data = (0..lh).flat_map(|_| (0..lw).map(move |x| if x < lw / 2 { 255 } else { 0 })).collect(); + spec.mask = Some(MaskSpec { rect: l.rect, data, default_color: 0, disabled: false }); + } + if l.shadow { + spec.extra_blocks.push(TaggedBlock::new(*b"lfx2", drop_shadow())); + } + b.push_layer(spec); + } + b.end_group()?; + } + let mut merged = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h { + for x in 0..w { + merged.extend(self.expected(x, y)); + merged.push(255); + } + } + b.composite(PixelData::Rgba8(merged)); + b.build() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn presets_respect_photoshop_limits() { + let psd = PSD_2GB.file_len().unwrap(); + assert!(psd < PSD_MAX_FILE_BYTES && psd > 2_000_000_000, "{psd}"); + const { assert!(PSD_2GB.width <= PSD_MAX_SIDE && PSD_2GB.height <= PSD_MAX_SIDE) }; + for c in PSB_CASES { + assert!(c.file_len().is_ok() && c.width <= 300_000, "{}", c.name); + } + assert!(PSB_4GB.file_len().unwrap() > 4 << 30); + // A PSD too big for its 32-bit lengths is refused, not wrapped. + let huge = Streamed { width: 33_000, height: 33_000, ..PSD_2GB }; + assert!(huge.file_len().is_err()); + assert!(huge.write_to(&mut io::sink()).is_err()); + } + + /// Small streamed files parse, and their pixels are [`sample`]'s. + #[test] + fn small_streamed_files_parse() { + for version in [Version::Psd, Version::Psb] { + for (rle, layered, depth) in [(false, true, 8), (true, true, 8), (true, false, 16), (false, false, 8)] { + let c = Streamed { name: "t", version, width: 300, height: 7, depth, rle, layered }; + let mut out = Vec::new(); + let n = c.write_to(&mut out).unwrap(); + assert_eq!(n, out.len() as u64, "{c:?}"); + let file = PsdFile::from_bytes(&out).unwrap(); + let merged = file.image_data.decode(&file.header).unwrap(); + let bps = usize::from(depth / 8); + let plane = 300 * 7 * bps; + for (x, y, ch) in [(0u32, 0u32, 0u32), (299, 6, 2), (150, 3, 1)] { + let i = ch as usize * plane + (y as usize * 300 + x as usize) * bps; + assert_eq!(merged[i], c.sample(x, y, ch), "{c:?} ({x}, {y}) {ch}"); + } + assert_eq!(file.layers().len(), usize::from(layered), "{c:?}"); + } + } + } + + #[test] + fn grid_layout_is_consistent() { + for g in [LAYERED_20MP, LAYOUT_150] { + let last = g.layer(g.layers - 1).unwrap(); + assert!(last.rect.right <= g.width as i32 && last.rect.bottom <= g.height as i32); + assert!(g.layer(g.layers).is_none()); + let all: Vec<_> = (0..g.layers).filter_map(|i| g.layer(i)).collect(); + assert!(all.iter().any(|l| l.masked) && all.iter().any(|l| l.shadow)); + for (x, y) in g.sample_points() { + assert!(x < g.width && y < g.height); + } + } + assert!((0..LAYOUT_150.layers).filter_map(|i| LAYOUT_150.layer(i)).any(|l| l.hidden)); + } + + /// A small grid builds, parses, and its merged image is [`Grid::expected`]. + #[test] + fn small_grid_builds() { + let g = Grid { width: 120, height: 90, layers: 14, cols: 4 }; + let file = g.build(Version::Psd).unwrap(); + let bytes = file.to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + // Background + 14 layers + 4 groups (two records each). + assert_eq!(file.layers().len(), 1 + 14 + 8); + let merged = file.image_data.decode(&file.header).unwrap(); + for (x, y) in g.sample_points() { + let want = g.expected(x, y); + for c in 0..3 { + assert_eq!(merged[c * 120 * 90 + (y * 120 + x) as usize], want[c], "({x}, {y}) {c}"); + } + } + } +} diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 00d73c4003..6fb47cbdb9 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -42,6 +42,7 @@ pub mod abr; pub mod aco; +pub mod act; pub mod ase; pub mod atn; pub mod blend; @@ -51,6 +52,8 @@ pub mod descriptor; pub mod error; pub mod file; pub mod filter_effects; +#[cfg(any(test, feature = "testgen"))] +pub mod fixtures; pub mod grd; pub mod hdr; pub mod header; diff --git a/crates/psd/src/metadata.rs b/crates/psd/src/metadata.rs index 9e85d8cee7..24d2eebca6 100644 --- a/crates/psd/src/metadata.rs +++ b/crates/psd/src/metadata.rs @@ -1,11 +1,12 @@ //! `shmd` (metadata setting) layer block: a list of keyed sub-blocks, such as `cmls` (layer comp -//! state, a versioned descriptor), `cust` and `mlst`. +//! state, a versioned descriptor), `cust` (the layer's modify time and XMP packet) and `mlst`. //! //! Layout (Adobe PSD spec, "Metadata setting"): u32 count, then per item the signature `8BIM`, //! a 4-byte key, a copy-on-sheet-duplication byte, 3 padding bytes, a u32 length and the data. //! Photoshop pads item data to an even length (the padding is part of the stored length), so //! parsing and writing are exact: `write(parse(b)) == b`. +use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor}; use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt}; @@ -71,10 +72,166 @@ pub fn write_shmd(items: &[MetadataItem]) -> Vec { out } +/// The `shmd` item that holds a layer's own metadata (its modify time and XMP). +const CUST: [u8; 4] = *b"cust"; +/// Key of a layer's XMP packet in the `cust` descriptor. +const LAYER_XMP: &str = "layerXMP"; + +// A layer's XMP packet (Photoshop's `layer.xmpMetadata.rawData`), as observed in files Photoshop +// 2026 saves: a `TEXT` item `layerXMP` (UTF-16 with a terminating NUL) in the `metadata` +// descriptor (version 16, class name a lone NUL) of the `shmd` item `cust`, after the +// `layerTime` double that every Photoshop layer has. A layer without XMP has no `layerXMP` item. + +fn pad_even(mut v: Vec) -> Vec { + if v.len() % 2 == 1 { + v.push(0); + } + v +} + +/// The `cust` descriptor of `item`, or `None` when it isn't one. +fn cust_descriptor(item: &MetadataItem) -> Option { + (item.key == CUST).then(|| VersionedDescriptor::parse_prefix(&item.data).ok().map(|(d, _)| d)).flatten() +} + +/// The XMP packet stored on a layer whose `shmd` block data is `shmd`, or `None` when it has +/// none (or the block can't be read). +pub fn layer_xmp(shmd: &[u8]) -> Option { + let items = parse_shmd(shmd).ok()?; + items.iter().filter_map(cust_descriptor).find_map(|d| match d.descriptor.get(LAYER_XMP) { + Some(Value::Text(t)) => Some(t.to_string_lossy()), + _ => None, + }) +} + +/// Splits a layer's XMP packet out of its `shmd` block data: the packet, and the block without +/// it (`None` when nothing is left). `None` when the block holds no packet, so the caller keeps +/// the original bytes. A `cust` item left empty is dropped, the other items are kept as stored. +pub fn take_layer_xmp(shmd: &[u8]) -> Option<(String, Option>)> { + let mut items = parse_shmd(shmd).ok()?; + let (i, mut vd) = items.iter().enumerate().find_map(|(i, it)| cust_descriptor(it).filter(|d| d.descriptor.get(LAYER_XMP).is_some()).map(|d| (i, d)))?; + let at = vd.descriptor.items.iter().position(|(k, _)| k.is(LAYER_XMP))?; + let xmp = match vd.descriptor.items.remove(at).1 { + Value::Text(t) => t.to_string_lossy(), + _ => return None, + }; + if vd.descriptor.items.is_empty() { + items.remove(i); + } else { + items[i].data = pad_even(vd.to_bytes()); + } + Some((xmp, (!items.is_empty()).then(|| write_shmd(&items)))) +} + +/// `shmd` block data (`shmd`, or a new block when `None`) with `xmp` as the layer's XMP packet, +/// written as Photoshop writes it: it replaces an existing packet in place, or is appended to +/// the `cust` descriptor (a new `cust` item when there is none). An unreadable block is an +/// error, so the caller never replaces data it couldn't read. +pub fn put_layer_xmp(shmd: Option<&[u8]>, xmp: &str) -> Result> { + let mut items = match shmd { + Some(b) => parse_shmd(b)?, + None => Vec::new(), + }; + let value = Value::Text(UnicodeString::new_nul(xmp)); + match items.iter().position(|it| it.key == CUST) { + Some(i) => { + let (mut vd, _) = VersionedDescriptor::parse_prefix(&items[i].data)?; + match vd.descriptor.items.iter_mut().find(|(k, _)| k.is(LAYER_XMP)) { + Some(entry) => entry.1 = value, + None => vd.descriptor.items.push((Id::new(LAYER_XMP), value)), + } + items[i].data = pad_even(vd.to_bytes()); + } + None => { + let d = Descriptor { name: UnicodeString::new_nul(""), class_id: Id::new("metadata"), items: vec![(Id::new(LAYER_XMP), value)] }; + items.push(MetadataItem::new(CUST, VersionedDescriptor::new(d).to_bytes())); + } + } + Ok(write_shmd(&items)) +} + #[cfg(test)] mod tests { use super::*; + fn unhex(lines: &[&str]) -> Vec { + let h: String = lines.concat(); + (0..h.len()).step_by(2).map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()).collect() + } + + /// `shmd` of a layer saved by Photoshop 2026 after a script set its XMP to [`PS_PACKET`]. + const PS_WITH_XMP: &[&str] = &[ + "000000013842494d63757374000000000000024800000010000000010000000000086d65746164617461000000020000", + "00096c6179657254696d65646f756241dab643ffd54363000000086c61796572584d505445585400000100003c007800", + "3a0078006d0070006d00650074006100200078006d006c006e0073003a0078003d002200610064006f00620065003a00", + "6e0073003a006d006500740061002f0022003e003c007200640066003a00520044004600200078006d006c006e007300", + "3a007200640066003d00220068007400740070003a002f002f007700770077002e00770033002e006f00720067002f00", + "31003900390039002f00300032002f00320032002d007200640066002d00730079006e007400610078002d006e007300", + "230022003e003c007200640066003a004400650073006300720069007000740069006f006e0020007200640066003a00", + "610062006f00750074003d0022002200200078006d006c006e0073003a00730061006d0070006c0065003d0022006800", + "7400740070003a002f002f006500780061006d0070006c0065002e0063006f006d002f00730061006d0070006c006500", + "2f0031002e0030002f0022003e003c00730061006d0070006c0065003a00760061006c00750065003e0061006c007000", + "680061002d003100320033003c002f00730061006d0070006c0065003a00760061006c00750065003e003c002f007200", + "640066003a004400650073006300720069007000740069006f006e003e003c002f007200640066003a00520044004600", + "3e003c002f0078003a0078006d0070006d006500740061003e000000", + ]; + /// `shmd` of a layer without XMP in the same file (its own `layerTime` only). + const PS_WITHOUT_XMP: &[&str] = + &["000000013842494d63757374000000000000003400000010000000010000000000086d6574616461746100000001000000096c6179657254696d65646f756241dab643ffd86bec00"]; + const PS_PACKET: &str = concat!( + r#""#, + r#"alpha-123"#, + r#""# + ); + + #[test] + fn reads_photoshop_layer_xmp() { + assert_eq!(layer_xmp(&unhex(PS_WITH_XMP)).as_deref(), Some(PS_PACKET)); + assert_eq!(layer_xmp(&unhex(PS_WITHOUT_XMP)), None); + assert!(take_layer_xmp(&unhex(PS_WITHOUT_XMP)).is_none()); + } + + #[test] + fn take_then_put_is_exact() { + let ps = unhex(PS_WITH_XMP); + let (xmp, rest) = take_layer_xmp(&ps).unwrap(); + assert_eq!(xmp, PS_PACKET); + let rest = rest.unwrap(); + // What's left is a layer without XMP, shaped as Photoshop writes one. + assert_eq!(rest.len(), unhex(PS_WITHOUT_XMP).len()); + assert_eq!(layer_xmp(&rest), None); + assert_eq!(put_layer_xmp(Some(&rest), &xmp).unwrap(), ps); + } + + #[test] + fn put_replaces_in_place_and_creates_a_block() { + let ps = unhex(PS_WITH_XMP); + let edited = put_layer_xmp(Some(&ps), "").unwrap(); + assert_eq!(layer_xmp(&edited).as_deref(), Some("")); + // `layerTime` is kept, still first. + let items = parse_shmd(&edited).unwrap(); + let d = cust_descriptor(&items[0]).unwrap().descriptor; + assert!(d.items[0].0.is("layerTime") && d.items[1].0.is(LAYER_XMP) && d.items.len() == 2); + + let fresh = put_layer_xmp(None, PS_PACKET).unwrap(); + assert_eq!(layer_xmp(&fresh).as_deref(), Some(PS_PACKET)); + assert_eq!(take_layer_xmp(&fresh), Some((PS_PACKET.to_string(), None))); + // Other items (here a layer comp state) stay as they were. + let comps = write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2])]); + let both = put_layer_xmp(Some(&comps), PS_PACKET).unwrap(); + assert_eq!(parse_shmd(&both).unwrap()[0], parse_shmd(&comps).unwrap()[0]); + assert_eq!(take_layer_xmp(&both).unwrap().1.unwrap(), comps); + } + + #[test] + fn put_refuses_an_unreadable_block() { + assert!(put_layer_xmp(Some(&[0, 0, 0, 9]), PS_PACKET).is_err()); + let bad_cust = write_shmd(&[MetadataItem::new(CUST, vec![0, 0, 0, 16, 1])]); + assert!(put_layer_xmp(Some(&bad_cust), PS_PACKET).is_err()); + assert_eq!(layer_xmp(&bad_cust), None); + assert!(take_layer_xmp(&bad_cust).is_none()); + } + #[test] fn roundtrip_exact() { let items = vec![MetadataItem::new(*b"cmls", vec![1, 2, 3]), MetadataItem { copy: true, ..MetadataItem::new(*b"cust", vec![9; 4]) }]; diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 31c85d4885..9a2cfe5285 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -859,15 +859,7 @@ pub mod transcode { // Type: version, transform, text version + descriptor, warp version + descriptor, // and the bounds as four 32-bit values (the specification says doubles; Photoshop // writes 16 bytes). - b"TySh" => { - self.u16()?; - self.f64s(6)?; - self.u16()?; - self.versioned_descriptor()?; - self.u16()?; - self.versioned_descriptor()?; - self.i32s(4)?; - } + b"TySh" => self.type_layer()?, b"vmsk" | b"vsms" => { self.u32()?; self.u32()?; @@ -1165,6 +1157,31 @@ pub mod transcode { // ---- descriptors ---- + /// Photoshop's Intel-order TIFF type blocks can use PSD byte order for the + /// text descriptor, then container order again for warp and bounds (#4758). + fn type_layer(&mut self) -> Result<()> { + self.u16()?; + self.f64s(6)?; + self.type_descriptor(50)?; + self.type_descriptor(1)?; + self.i32s(4) + } + + fn type_descriptor(&mut self, version: u16) -> Result<()> { + let [hi, lo] = version.to_be_bytes(); + let from = if self.from.is_little() && self.r.peek_rest().starts_with(&[hi, lo, 0, 0, 0, 16]) { ByteOrder::Big } else { self.from }; + let data = self.r.peek_rest(); + let mut descriptor = Tx::new(data, self.version, from, self.to); + descriptor.depth = self.depth; + descriptor.u16()?; + descriptor.versioned_descriptor()?; + let used = data.len() - descriptor.remaining(); + self.r.bytes(used)?; + self.warnings.append(&mut descriptor.warnings); + self.out.extend_from_slice(&descriptor.out); + Ok(()) + } + fn versioned_descriptor(&mut self) -> Result<()> { self.u32()?; self.descriptor() @@ -1284,6 +1301,7 @@ pub mod transcode { self.i32()?; } b"Idnt" | b"indx" => { + self.class()?; self.u32()?; } b"name" => { @@ -1410,9 +1428,27 @@ pub mod transcode { }) } - /// `FEid` / `FXid`: version, u64 length, items with per-channel planes (4-byte RLE counts). + /// `FEid` / `FXid`: version, then one section per smart object (u64 length, items with + /// per-channel planes and 4-byte RLE counts), each padded to 4 bytes. fn filter_effects(&mut self) -> Result<()> { self.u32()?; + loop { + let len = self.r.clone().len_field(true)?; + self.filter_effects_section()?; + // Mirrors `FilterEffects::parse`: the last section's padding may be cut short. + let pad = usize::try_from((4 - len % 4) % 4).unwrap_or(0).min(self.remaining()); + self.raw(pad)?; + let rest = self.r.peek_rest(); + if rest.len() <= 3 && rest.iter().all(|&b| b == 0) { + return Ok(()); + } + if rest.len() < 8 { + return Err(PsdError::invalid(format!("filter effects: {} unexpected bytes after the data", rest.len()))); + } + } + } + + fn filter_effects_section(&mut self) -> Result<()> { self.sized_region(true, |t| { while !t.r.is_empty() { t.pascal(1)?; @@ -1789,6 +1825,17 @@ mod tests { assert!(little.windows(11).any(|w| w == b"longKeyName"), "string ids are not reversed"); } + #[test] + fn descriptor_references_by_identifier_and_index_keep_their_class() { + use crate::descriptor::{Class, ReferenceItem}; + let class = || Class { name: UnicodeString::new(""), class_id: Id::new("Lyr ") }; + let desc = Descriptor::new("null") + .with("null", Value::Reference(vec![ReferenceItem::Identifier { class: class(), id: 7 }, ReferenceItem::Index { class: class(), index: 2 }])); + let mut data = 0u32.to_be_bytes().to_vec(); + data.extend(VersionedDescriptor::new(desc).to_bytes()); + block_rt(*b"lfx2", data); + } + #[test] fn type_block_layout() { let mut data = Vec::new(); @@ -1808,6 +1855,39 @@ mod tests { assert_eq!(&little[50..58], &1.0f64.to_le_bytes()); } + #[test] + fn photoshop_type_block_switches_to_big_endian_after_its_matrix() { + let mut data = Vec::new(); + data.put_u16(1); + for v in [1.8054202887117468, 0.0, -0.143, 1.8063853899082987, 247.25, 2864.5] { + data.put_f64(v); + } + data.put_u16(50); + let text = Descriptor::new("TxLr").with("Txt ", Value::Text(UnicodeString::new_nul("Synthetic headline"))); + data.extend(VersionedDescriptor::new(text).to_bytes()); + data.put_u16(1); + data.extend(VersionedDescriptor::new(Descriptor::new("warp")).to_bytes()); + for v in [0, 0, 120, 30] { + data.put_i32(v); + } + let (_, mut little) = block_rt(*b"TySh", data.clone()); + // Photoshop uses big-endian text but returns to little-endian for warp/bounds. + let (_, used) = VersionedDescriptor::parse_prefix(&data[52..]).unwrap(); + little[98..100 + used].copy_from_slice(&data[50..52 + used]); + let (back, warnings) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(!back.global_blocks[0].is_foreign_order()); + assert_eq!(back.global_blocks[0].data, data); + // The normalized model remains writable in either order. + for order in [ByteOrder::Big, ByteOrder::Little] { + let (bytes, warnings) = back.to_bytes(order).unwrap(); + assert!(warnings.is_empty()); + let (again, warnings) = ImageSourceData::from_bytes(&bytes).unwrap(); + assert!(warnings.is_empty()); + assert_eq!(again.global_blocks[0].data, data); + } + } + #[test] fn legacy_effects_and_adjustments() { // lrFX with a drop shadow and a solid fill. @@ -2052,6 +2132,39 @@ mod tests { assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx); } + /// Photoshop writes one `FEid` section per smart object: every section's lengths and + /// numbers are swapped, not only the first one's. + #[test] + fn filter_effects_block_swaps_every_section() { + use crate::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem}; + let rect = crate::layer::Rect { top: 0, left: 0, bottom: 2, right: 3 }; + let item = |id: &str, v: u8| FilterEffectsItem { + id: id.into(), + version: 1, + rect, + depth: 8, + max_channels: 1, + slots: vec![Some(EffectsPlane::encode(Compression::Raw, &[v; 6], 3, 2, 8).unwrap()), None, None], + mask: None, + }; + let fx = FilterEffects { version: 1, items: vec![item("first", 1), item("second-object", 2)] }; + let data = fx.to_bytes(); + let len1 = u64::from_be_bytes(data[4..12].try_into().unwrap()) as usize; + let second = 12 + len1 + (4 - len1 % 4) % 4; + let len2 = &data[second..second + 8]; + block_rt(*b"FEid", data.clone()); + let d = ImageSourceData { global_blocks: vec![TaggedBlock::new(*b"FEid", data.clone())], ..Default::default() }; + let (little, _) = d.to_bytes(ByteOrder::Little).unwrap(); + // 36-byte signature, then `MIB8`, the reversed key and a u32 length: the block data. + let body = &little[36 + 12..]; + let mut swapped = len2.to_vec(); + swapped.reverse(); + assert_eq!(&body[second..second + 8], &swapped[..], "second section's length is little-endian"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(FilterEffects::parse(&back.global_blocks[0].data).unwrap(), fx); + } + #[test] fn metadata_setting_items() { let desc = VersionedDescriptor::new(Descriptor::new("null")).to_bytes(); diff --git a/crates/psd/tests/builder.rs b/crates/psd/tests/it/builder.rs similarity index 100% rename from crates/psd/tests/builder.rs rename to crates/psd/tests/it/builder.rs diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/it/corpus.rs similarity index 100% rename from crates/psd/tests/corpus.rs rename to crates/psd/tests/it/corpus.rs diff --git a/crates/psd/tests/it/corpus_atn.rs b/crates/psd/tests/it/corpus_atn.rs new file mode 100644 index 0000000000..32f6367714 --- /dev/null +++ b/crates/psd/tests/it/corpus_atn.rs @@ -0,0 +1,60 @@ +//! Corpus test (feature `corpus`; run with `cargo xtask test-corpus`): every `.atn` under +//! `corpus/atn/` (gitignored, fetched by `cargo xtask corpus --atn`) parses, and writing it back +//! reproduces the bytes exactly. A missing corpus fails. +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_psd::atn; + +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; raise, never lower. +const FLOOR: usize = 16; + +#[test] +fn atn_corpus_parses_and_is_byte_stable() { + 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 actions, mut steps) = (0usize, 0usize); + for p in &files { + let name = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let bytes = std::fs::read(p).unwrap(); + let file = match atn::parse(&bytes) { + Ok(f) => f, + Err(e) => { + failures.push(format!("{name}: {e}")); + continue; + } + }; + if !file.warnings.is_empty() { + failures.push(format!("{name}: warnings {:?}", file.warnings)); + } + actions += file.actions.len(); + steps += file.actions.iter().map(|a| a.items.len()).sum::(); + match atn::write(&file) { + Ok(written) if written == bytes => {} + Ok(written) => failures.push(format!("{name}: rewritten bytes differ ({} vs {} bytes)", written.len(), bytes.len())), + Err(e) => failures.push(format!("{name}: write failed: {e}")), + } + println!("{name}: {} actions, {} steps", file.actions.len(), file.actions.iter().map(|a| a.items.len()).sum::()); + } + println!("{} files, {actions} actions, {steps} steps", files.len()); + assert!(files.len() >= FLOOR, "only {} .atn files; the corpus floor is {FLOOR}", files.len()); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} diff --git a/crates/psd/tests/layer_block_order.rs b/crates/psd/tests/it/layer_block_order.rs similarity index 100% rename from crates/psd/tests/layer_block_order.rs rename to crates/psd/tests/it/layer_block_order.rs diff --git a/crates/psd/tests/it/main.rs b/crates/psd/tests/it/main.rs new file mode 100644 index 0000000000..ee6879aa66 --- /dev/null +++ b/crates/psd/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, linked into one test binary. + +mod builder; +mod corpus; +mod corpus_atn; +mod layer_block_order; +mod malformed; +mod merged_recovery; +mod proptests; +mod roundtrip; +mod swatch_malformed; +mod tree; diff --git a/crates/psd/tests/malformed.rs b/crates/psd/tests/it/malformed.rs similarity index 100% rename from crates/psd/tests/malformed.rs rename to crates/psd/tests/it/malformed.rs diff --git a/crates/psd/tests/merged_recovery.rs b/crates/psd/tests/it/merged_recovery.rs similarity index 100% rename from crates/psd/tests/merged_recovery.rs rename to crates/psd/tests/it/merged_recovery.rs diff --git a/crates/psd/tests/proptests.rs b/crates/psd/tests/it/proptests.rs similarity index 100% rename from crates/psd/tests/proptests.rs rename to crates/psd/tests/it/proptests.rs diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/it/roundtrip.rs similarity index 100% rename from crates/psd/tests/roundtrip.rs rename to crates/psd/tests/it/roundtrip.rs diff --git a/crates/psd/tests/swatch_malformed.rs b/crates/psd/tests/it/swatch_malformed.rs similarity index 100% rename from crates/psd/tests/swatch_malformed.rs rename to crates/psd/tests/it/swatch_malformed.rs diff --git a/crates/psd/tests/tree.rs b/crates/psd/tests/it/tree.rs similarity index 100% rename from crates/psd/tests/tree.rs rename to crates/psd/tests/it/tree.rs diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 958ab38b53..f0d9b63b78 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -14,7 +14,7 @@ use std::collections::BTreeMap; use std::fmt; use std::sync::Arc; -use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, write_sample}; +use photocraft_color::{ColorMode, PixelFormat, SampleType, read_sample, read_samples, write_sample}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; /// A checked surface allocation could not be represented or reserved. @@ -97,12 +97,17 @@ impl Tile { return None; } let ts = TILE_SIZE as usize; + // A row of `dp`, compared block by block: an untouched row (most rows of a soft brush + // stroke's tiles) costs a few slice compares instead of one per pixel. A pixel differs + // where any of its bytes does, so the first and last differing bytes give the first and + // last differing pixels. + let blank = dp.repeat(ts); let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); + let Some(first) = first_difference(row, &blank) else { continue }; + let Some(last) = last_difference(row, &blank) else { continue }; + x0 = x0.min(first / bpp); + x1 = x1.max(last / bpp + 1); y0 = y0.min(y); y1 = y + 1; } @@ -110,6 +115,21 @@ impl Tile { } } +/// Bytes compared at once while looking for a difference in a tile row. +const DIFF_BLOCK: usize = 64; + +/// Index of the first byte where `a` and `b` (the same length) differ. +fn first_difference(a: &[u8], b: &[u8]) -> Option { + let (k, (x, y)) = a.chunks(DIFF_BLOCK).zip(b.chunks(DIFF_BLOCK)).enumerate().find(|(_, (x, y))| x != y)?; + x.iter().zip(y).position(|(p, q)| p != q).map(|i| k * DIFF_BLOCK + i) +} + +/// Index of the last byte where `a` and `b` (the same length) differ. +fn last_difference(a: &[u8], b: &[u8]) -> Option { + let (k, (x, y)) = a.chunks(DIFF_BLOCK).zip(b.chunks(DIFF_BLOCK)).enumerate().rfind(|(_, (x, y))| x != y)?; + x.iter().zip(y).rposition(|(p, q)| p != q).map(|i| k * DIFF_BLOCK + i) +} + #[derive(Clone, Debug)] pub struct Surface { format: PixelFormat, @@ -132,19 +152,18 @@ fn sample_buffer<'a>(channels: usize, stack: &'a mut [f32], heap: &'a mut Vec>, + of: std::collections::HashMap>, canonical: Vec>, } impl UniformTiles { /// The tile to share for a target reading window `r` of `s`, when every tile under it holds /// one and the same pixel value (`r` must lie on existing tiles). - fn window(&mut self, s: &Surface, r: Rect) -> Option> { - let bpp = s.format.bytes_per_pixel(); + fn window(&self, s: &Surface, r: Rect) -> Option> { let mut found = None; for c in r.tiles() { let t = s.tiles.get(&c)?; - let k = self.value_of(t, bpp)?; + let k = (*self.of.get(&(Arc::as_ptr(t) as usize))?)?; if found.is_some_and(|f| f != k) { return None; } @@ -154,7 +173,8 @@ impl UniformTiles { } fn value_of(&mut self, t: &Arc, bpp: usize) -> Option { - if let Some(k) = self.of.get(&Arc::as_ptr(t)) { + let key = Arc::as_ptr(t) as usize; + if let Some(k) = self.of.get(&key) { return *k; } let mut px = t.data.chunks_exact(bpp); @@ -165,7 +185,7 @@ impl UniformTiles { self.canonical.len() - 1 } }); - self.of.insert(Arc::as_ptr(t), k); + self.of.insert(key, k); k } } @@ -321,10 +341,20 @@ impl Surface { if !self.tiles.contains_key(&c) { let tile = Arc::new(Tile::try_filled(&self.format, &self.default_pixel)?); self.tiles.insert(c, tile); - } else if self.tiles.get(&c).is_some_and(|t| Arc::strong_count(t) > 1) { - let original = self.tiles.get(&c).ok_or(AllocationError::SizeOverflow)?; - let tile = Arc::new(original.try_clone()?); - self.tiles.insert(c, tile); + } else if self.tiles.get_mut(&c).is_some_and(|t| Arc::get_mut(t).is_none()) { + let original = self.tiles.remove(&c).ok_or(AllocationError::SizeOverflow)?; + let tile = match Arc::try_unwrap(original) { + // Weak cache references must not force a pixel-buffer copy. + Ok(tile) => tile, + Err(original) => match original.try_clone() { + Ok(tile) => tile, + Err(error) => { + self.tiles.insert(c, original); + return Err(error); + } + }, + }; + self.tiles.insert(c, Arc::new(tile)); } self.tiles.get_mut(&c).and_then(Arc::get_mut).ok_or(AllocationError::SizeOverflow) } @@ -503,9 +533,7 @@ impl Surface { match tile { Some(t) => { let base = (((y - tc.ty * TILE_SIZE) as usize) * TILE_SIZE as usize + (tr.x0 - tc.tx * TILE_SIZE) as usize) * n; - for (i, d) in dst.iter_mut().enumerate() { - *d = read_sample(&t.data, self.format.sample, base + i); - } + read_samples(&t.data, self.format.sample, base, dst); } None if n == 1 => dst.fill(dp[0]), None => { @@ -529,8 +557,8 @@ impl Surface { let n = fmt.channels(); let mut stack = [0.0; 8]; let mut heap = Vec::new(); - let px = self.default_samples(&mut stack, &mut heap); - let dp = to_rgba(&fmt, px); + let dp = to_rgba(&fmt, self.default_samples(&mut stack, &mut heap)); + let mut row = Vec::new(); for tc in r.tiles() { let tr = tc.rect().intersect(&r); let tile = self.tiles.get(&tc); @@ -574,11 +602,17 @@ impl Surface { } } _ => { - for (i, d) in dst.iter_mut().enumerate() { - for (c, v) in px.iter_mut().enumerate() { - *v = read_sample(&t.data, fmt.sample, base + i * n + c); - } - *d = to_rgba(&fmt, px); + // The row's samples in one pass, then its colour model resolved once: + // RGB and grey take their channels as `to_rgba` does, other models go + // through it. + row.resize(dst.len() * n, 0.0); + read_samples(&t.data, fmt.sample, base, &mut row); + let a = |s: &[f32], i: usize| if fmt.alpha { s[i] } else { 1.0 }; + let pixels = dst.iter_mut().zip(row.chunks_exact(n)); + match fmt.mode { + ColorMode::Rgb => pixels.for_each(|(d, s)| *d = [s[0], s[1], s[2], a(s, 3)]), + ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone => pixels.for_each(|(d, s)| *d = [s[0], s[0], s[0], a(s, 1)]), + _ => pixels.for_each(|(d, s)| *d = to_rgba(&fmt, s)), } } } @@ -830,6 +864,9 @@ impl Surface { let moved = content.translate(dx, dy); let targets: std::collections::BTreeSet = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect(); let mut uniform = UniformTiles::default(); + for (_, t) in sources() { + uniform.value_of(t, bpp); + } // The fill row: one tile row of the default pixel, so the regions a shift vacates // fill with bulk copies (empty when the default is zero — the tile starts zeroed). @@ -846,7 +883,7 @@ impl Surface { // One tile row of the default pixel, to compare a finished tile with a row at a time. let default_row = self.default_pixel.repeat(TILE_SIZE.unsigned_abs() as usize); - for dc in targets { + let build_target = |dc: TileCoord| -> Option<(TileCoord, Arc)> { let dr = dc.rect(); let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); // Every pixel of the target comes from a source tile (none from the default pixel). @@ -858,10 +895,7 @@ impl Surface { && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); if covered && let Some(shared) = uniform.window(self, sr) { // A tile of only the default pixel is left out, as `prune` would. - if shared.data.get(..bpp) != Some(&*self.default_pixel) { - out.tiles.insert(dc, shared); - } - continue; + return (shared.data.get(..bpp) != Some(&*self.default_pixel)).then_some((dc, shared)); } let mut tile = Tile { data: vec![0u8; (TILE_SIZE * TILE_SIZE) as usize * bpp].into_boxed_slice() }; @@ -943,9 +977,16 @@ impl Surface { } // A target that got only default pixels isn't stored, as `prune` would drop it. Checked // after the copy, a row at a time: a tile with content stops at its first such row. - if tile.data.chunks(default_row.len()).any(|row| row != default_row) { - out.tiles.insert(dc, Arc::new(tile)); - } + tile.data.chunks(default_row.len()).any(|row| row != default_row).then(|| (dc, Arc::new(tile))) + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.tiles = targets.into_par_iter().filter_map(build_target).collect(); + } + #[cfg(target_arch = "wasm32")] + { + out.tiles = targets.into_iter().filter_map(build_target).collect(); } out } @@ -1473,6 +1514,23 @@ mod tests { assert_eq!(surface, before); } + #[test] + fn fallible_tile_edits_move_weakly_cached_pixels_and_copy_shared_pixels() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut surface = Surface::new(fmt); + let coord = TileCoord::new(0, 0); + let pixels = surface.try_tile_mut(coord).unwrap().bytes().as_ptr(); + let weak = Arc::downgrade(surface.tile(coord).unwrap()); + let tile = surface.try_tile_mut(coord).unwrap(); + assert_eq!(tile.bytes().as_ptr(), pixels); + assert!(weak.upgrade().is_none()); + let snapshot = surface.clone(); + surface.try_write_region(Rect::new(0, 0, 1, 1), &[1.0; 4]).unwrap(); + assert_eq!(snapshot.pixel(0, 0), vec![0.0; 4]); + assert_eq!(surface.pixel(0, 0), vec![1.0; 4]); + } + } + #[test] fn fill_rect_shares_covered_tiles_and_edits_stay_local() { // A 300000² background used to materialize ~360 GB of tiles (#705). @@ -1603,6 +1661,31 @@ mod tests { } } + #[test] + fn rgba_reads_match_individual_pixels_bitwise_in_every_model_and_depth() { + // Across tile edges and a missing tile; float samples include -0, out-of-range and NaN. + let rect = Rect::new(-3, -1, 300, 2); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Lab, ColorMode::Cmyk] { + for depth in SampleType::ALL { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, depth, alpha); + let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + let values = [-0.0, 0.1, 0.5, 1.0, 1.5, -0.25, f32::NAN, 0.75]; + for (i, x) in (-2..260).enumerate() { + let px: Vec = (0..fmt.channels()).map(|c| values[(i + c * 3) % values.len()]).collect(); + s.write_pixel(x, 0, &px); + } + let mut out = vec![[0.0; 4]; (rect.width() * rect.height()) as usize]; + s.read_rgba_into(rect, &mut out); + for (i, actual) in out.iter().enumerate() { + let expected = s.rgba(rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32); + assert_eq!(actual.map(f32::to_bits), expected.map(f32::to_bits), "{fmt:?}, pixel={i}"); + } + } + } + } + } + #[test] fn tile_content_bounds_scans_against_the_given_pixel() { let mut s = Surface::new(PixelFormat::GRAY8); @@ -1615,6 +1698,42 @@ mod tests { assert_eq!(t.content_bounds(&[]), None); } + #[test] + fn tile_content_bounds_match_a_per_pixel_scan() { + // One differing byte at each position of a pixel, at and around the compare-block + // boundaries, in every pixel size; and a pixel that differs from `dp` in every byte. + fn per_pixel(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { + let (bpp, ts) = (dp.len(), TILE_SIZE as usize); + let mut b: Option<(usize, usize, usize, usize)> = None; + for (i, px) in t.bytes().chunks_exact(bpp).enumerate() { + if px != dp { + let (x, y) = (i % ts, i / ts); + let (x0, y0, x1, y1) = b.unwrap_or((x, y, x, y)); + b = Some((x0.min(x), y0.min(y), x1.max(x), y1.max(y))); + } + } + b.map(|(x0, y0, x1, y1)| (x0 as u16, y0 as u16, x1 as u16 + 1, y1 as u16 + 1)) + } + for bpp in [1, 2, 3, 4, 6, 8, 16] { + let dp: Vec = (0..bpp).map(|i| (i * 37 + 5) as u8).collect(); + let row = TILE_SIZE as usize * bpp; + for spots in [vec![], vec![0], vec![row - 1], vec![63, 64], vec![row * 7 + 127, row * 7 + 128, row * 200 + 5], vec![row * 255 + row / 2]] { + for byte in 0..bpp { + let mut data = dp.repeat(TILE_SIZE as usize * TILE_SIZE as usize); + for &s in &spots { + data[s - s % bpp + byte] ^= 0x5a; + } + let t = Tile { data: data.into_boxed_slice() }; + assert_eq!(t.content_bounds(&dp), per_pixel(&t, &dp), "bpp {bpp}, spots {spots:?}, byte {byte}"); + } + } + let mut data = dp.repeat(TILE_SIZE as usize * TILE_SIZE as usize); + data[row * 3 + 9 * bpp..row * 3 + 10 * bpp].iter_mut().for_each(|b| *b = !*b); + let t = Tile { data: data.into_boxed_slice() }; + assert_eq!(t.content_bounds(&dp), Some((9, 3, 10, 4)), "bpp {bpp}"); + } + } + #[test] fn translated_matches_reading_the_moved_pixels() { // Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default. diff --git a/crates/raw/README.md b/crates/raw/README.md index bb4815a4fc..e266c64cde 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -25,7 +25,7 @@ Implemented only from public specifications, papers and observation of files: * ITU-T T.81 (ISO 10918-1) Annex H: lossless JPEG, process 14 ("LJ92"). * The published description of Canon's CR2 container (header, raw IFD, slice tag 0xC640). * Publicly documented maker-note / private tags (ExifTool's tag tables): Canon ModelID (0x0010), - SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C) and BlackLevel (0x003D), Sony + SensorInfo (0x00E0) and ColorData (0x4001), Nikon WB_RBLevels (0x000C), ColorSpace (0x001E) and BlackLevel (0x003D), Sony BlackLevel (0x7310), WB_RGGBLevels (0x7313), SonyRawFileType (0x7000) and SonyToneCurve (0x7010), the PanasonicRaw IFD0 tags, Olympus ImageProcessing (0x2040) and CameraSettings (0x2020) preview tags, the Fujifilm RAF header and metadata records (RawImageCropTopLeft / @@ -119,7 +119,11 @@ out). One cell in three is held out: the look is used only when it shows the sam the matrix is plausible and the held-out mean CIE76 ΔE drops to at most 85 % of the fallback's. Pass it as `DevelopOptions::camera_look`; `develop` then notes "colour fitted to the camera's embedded JPEG (mean ΔE …)". The crate cannot decode JPEG itself: `photocraft-io` decodes the -smallest preview at least 512 pixels long, fits and develops in one go. +smallest preview at least 512 pixels long, fits and develops in one go. A preview without an ICC +profile is read in the colour space the raw states (`embedded_preview_color_space`): the EXIF +ColorSpace (a Sony body set to Adobe RGB writes Uncalibrated with Interoperability "R03"), else a +Nikon maker note's ColorSpace (0x001E; Nikon writes no EXIF ColorSpace), sRGB when the file says +nothing. Camera Raw's tuned import fits at the default exposure and as-shot white balance first, then applies the user's Exposure, Temperature and Tint to that same rendering. The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 @@ -149,7 +153,10 @@ Raw rendering (quarter size, medians over the frames) the measured profile is 0. luminance with a mean CIE76 ΔE of 1.8; the look fitted to the embedded JPEG would be 0.30 stop and ΔE 6.4, as it follows the camera's picture style instead. The open-time re-develop (`photocraft-io`'s `import_raw_tuned`) and the tone curve its previews use (`tone_curve`) take the -same look as the default open, so changing a slider does not change the colour rendering. +same look as the default open, so changing a slider does not change the colour rendering. The D4's +saturation level was measured on 14-bit files and follows the sample depth (a 12-bit frame is the +14-bit one shifted right by two, so its level is a quarter of it): the three public 12-bit D4 files +develop within 0.02 stop of their 14-bit twin. ## Tools diff --git a/crates/raw/examples/inspect.rs b/crates/raw/examples/inspect.rs index ca7b136a90..4dd6e5709a 100644 --- a/crates/raw/examples/inspect.rs +++ b/crates/raw/examples/inspect.rs @@ -1,5 +1,5 @@ //! Prints the raw metadata of files: `cargo run -p photocraft-raw --example inspect -- a.cr2 b.nef` -//! (handy while adding corpus files; the oracle expectations in `tests/raw_corpus.rs` come from it). +//! (handy while adding corpus files; the oracle expectations in `tests/it/raw_corpus.rs` come from it). use std::env; fn main() { diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs index b71e2393bd..6b1722b720 100644 --- a/crates/raw/examples/rawinfo.rs +++ b/crates/raw/examples/rawinfo.rs @@ -9,7 +9,7 @@ use std::time::Instant; -use photocraft_raw::{Demosaic, DevelopOptions, decode, develop_sensor, dump_structure, embedded_preview, identify}; +use photocraft_raw::{Demosaic, DevelopOptions, decode, develop_sensor, dump_structure, embedded_preview, embedded_preview_color_space, identify}; fn main() { let mut args = std::env::args().skip(1); @@ -76,7 +76,13 @@ fn main() { println!("{}", dump_structure(&bytes)); } if let Some(p) = embedded_preview(&bytes) { - println!(" preview: {}x{} JPEG, {} bytes", p.width, p.height, p.jpeg.len()); + println!( + " preview: {}x{} JPEG, {} bytes, colour space stated by the file: {:?}", + p.width, + p.height, + p.jpeg.len(), + embedded_preview_color_space(&bytes) + ); if let Some(dir) = png.as_ref().filter(|d| std::path::Path::new(d).is_dir()) { let stem = std::path::Path::new(&f).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); let _ = std::fs::write(format!("{dir}/{stem}.preview.jpg"), p.jpeg); diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 84204c6bce..d7ae5e84d7 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -11,7 +11,9 @@ //! (2 px of optical black on the left, 50 + 8 on the right show as a purple stripe otherwise). The //! data is already black-subtracted (the darkest image pixels reach 0). Green saturates at //! 15546–15672 while red and blue reach 15687–16383, so one level of 15520 makes clipped -//! highlights white without shifting the white balance. Matrix, curve and black point copy +//! highlights white without shifting the white balance. That level is for 14-bit files: the body +//! also writes 12-bit ones, whose samples are the 14-bit values shifted right by two, so the +//! level follows the sample depth (`Profile::white_at`). Matrix, curve and black point copy //! Photoshop Camera Raw's default rendering of these files, measured black-box: 29 NEFs opened //! in Photoshop 25.4 against our own linear development of the same data (8 × 8 means, pixels //! clipped in neither). The matrix is our first ForwardMatrix (fitted to the camera's embedded @@ -92,7 +94,10 @@ struct Profile { size: (usize, usize), /// The image area: x, y, width, height. crop: (usize, usize, usize, usize), + /// The saturation level, in codes of `white_bits`-bit samples. white: f32, + /// The sample depth `white` was measured at. + white_bits: u32, /// The black level is known to be 0 (the data is black-subtracted in camera). black_zero: bool, forward: Mat3, @@ -102,12 +107,25 @@ struct Profile { black_point: f32, } +impl Profile { + /// The saturation level for `bits`-bit samples. A body that writes several depths stores the + /// lower ones as the deepest value shifted right (a 12-bit D4 sample is the 14-bit sample + /// divided by four), so the level scales by the same power of two. At the depth the level was + /// measured at it is returned unchanged. + fn white_at(&self, bits: u32) -> f32 { + let bits = bits.clamp(1, 16); + let measured = self.white_bits.clamp(1, 16); + if bits >= measured { self.white * f32::from(1u16 << (bits - measured)) } else { self.white / f32::from(1u16 << (measured - bits)) } + } +} + const PROFILES: &[Profile] = &[Profile { make: "NIKON CORPORATION", model: "NIKON D4", size: (4992, 3292), crop: (8, 6, 4928, 3280), white: 15520.0, + white_bits: 14, black_zero: true, forward: D4_FORWARD, color: D4_COLOR, @@ -148,8 +166,9 @@ const IMAGE_AREAS: &[ImageArea] = &[ ]; /// Applies the measured profile of the camera that made `s`, when there is one, -/// and its measured image area when the profile is not measured yet. -pub(crate) fn apply(s: &mut Sensor) { +/// and its measured image area when the profile is not measured yet. `bits` is the depth of +/// the stored samples (the profile's saturation level depends on it). +pub(crate) fn apply(s: &mut Sensor, bits: u32) { let (Some(make), Some(model)) = (s.make.as_deref(), s.model.as_deref()) else { return }; let (make, model) = (make.trim(), model.trim()); if let Some(a) = IMAGE_AREAS.iter().find(|a| a.make == make && a.model == model && a.size == (s.width, s.height) && s.crop == s.active) { @@ -164,7 +183,7 @@ pub(crate) fn apply(s: &mut Sensor) { } let (x, y, w, h) = p.crop; s.crop = Rect::new(x, y, w, h); - s.white = [p.white; 3]; + s.white = [p.white_at(bits); 3]; if p.black_zero { s.warnings.retain(|w| !w.contains("black level not recorded")); } @@ -217,7 +236,7 @@ mod tests { #[test] fn a_d4_nef_gets_its_crop_levels_matrix_and_curve() { let mut s = nef("NIKON D4", 4992, 3292); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(8, 6, 4928, 3280)); assert_eq!(s.white, [15520.0; 3]); assert_eq!(s.color.calibrations.len(), 1); @@ -229,6 +248,28 @@ mod tests { assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some()); } + #[test] + fn the_d4_white_level_follows_the_sample_depth() { + // 14 bits is the depth it was measured at: unchanged. + let mut s = nef("NIKON D4", 4992, 3292); + apply(&mut s, 14); + assert_eq!(s.white, [15520.0; 3]); + // A 12-bit sample is the 14-bit one shifted right by two. + let mut s = nef("NIKON D4", 4992, 3292); + apply(&mut s, 12); + assert_eq!(s.white, [3880.0; 3]); + // Everything else of the profile is independent of the depth. + assert_eq!(s.crop, Rect::new(8, 6, 4928, 3280)); + assert_eq!(s.tone_curve.len(), D4_TONE.len()); + assert!(s.warnings.is_empty()); + // Other depths scale the same way and stay inside their own range; nonsense stays finite. + for (bits, white) in [(1, 15520.0 / 8192.0), (8, 15520.0 / 64.0), (13, 7760.0), (16, 62080.0), (0, 15520.0 / 8192.0), (200, 62080.0)] { + let mut s = nef("NIKON D4", 4992, 3292); + apply(&mut s, bits); + assert_eq!(s.white, [white; 3], "{bits} bits"); + } + } + #[test] fn d5100_image_area_respects_explicit_crops_and_exact_sensor_size() { let mut s = nef("NIKON D5100", 4992, 3280); @@ -236,7 +277,7 @@ mod tests { s.active = Rect::new(0, 0, 4984, 3280); s.crop = s.active; let wb = s.camera_wb; - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(8, 8, 4928, 3264)); assert_eq!(s.active, Rect::new(0, 0, 4984, 3280)); assert_eq!(s.camera_wb, wb); @@ -244,11 +285,11 @@ mod tests { assert_eq!(s.white, [16383.0; 3]); s.crop = Rect::new(16, 12, 4800, 3200); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(16, 12, 4800, 3200)); for (model, w, h) in [("NIKON D5100", 2496, 1640), ("NIKON D5200", 4992, 3280)] { let mut other = nef(model, w, h); - apply(&mut other); + apply(&mut other, 14); assert_eq!(other.crop, other.active); } } @@ -256,36 +297,36 @@ mod tests { #[test] fn measured_image_areas_crop_the_borders_without_touching_the_rest() { let mut s = nef("NIKON D800E", 7424, 4924); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(8, 6, 7360, 4912)); // Only the image area is measured: levels, colour and tone stay untouched. assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); assert_eq!(s.white, [16383.0; 3]); let mut s = sensor("SONY", "ILCE-7", 6048, 4024); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(12, 12, 6000, 4000)); // Another size or model of the same maker is left alone. let mut s = nef("NIKON D800E", 2496, 1646); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, 2496, 1646)); let mut s = nef("NIKON D800", 7424, 4924); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, 7424, 4924)); } #[test] fn d90_image_area_matches_the_camera_jpeg() { let mut s = nef("NIKON D90", 4352, 2868); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(10, 10, 4288, 2848)); // Only the image area is measured: levels, colour and tone stay untouched. assert!(s.color.calibrations.is_empty() && s.tone_curve.is_empty()); assert_eq!(s.white, [16383.0; 3]); // An explicit crop in the file wins over the measured area. s.crop = Rect::new(0, 0, 4288, 2848); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, 4288, 2848)); } @@ -298,7 +339,7 @@ mod tests { calibrations: vec![Calibration { temperature: 6504.0, color_matrix: IDENTITY, forward_matrix: None, camera_calibration: IDENTITY }], ..ColorInfo::default() }; - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.color.calibrations.len(), 1); assert_eq!(s.color.calibrations[0].temperature, 6504.0, "the file's own calibration stands"); assert!(s.tone_curve.is_empty() && s.black_point == 0.0); @@ -309,7 +350,7 @@ mod tests { fn other_cameras_and_sizes_are_left_alone() { for (model, w, h) in [("NIKON D4S", 4992, 3292), ("NIKON D4", 2496, 1646), ("NIKON D800", 7424, 4924)] { let mut s = nef(model, w, h); - apply(&mut s); + apply(&mut s, 14); assert_eq!(s.crop, Rect::new(0, 0, w, h), "{model} {w}x{h}"); assert!(s.tone_curve.is_empty() && s.color.calibrations.is_empty() && s.white == [16383.0; 3]); assert_eq!(s.warnings.len(), 1); diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index 5d28508750..71a8384cd0 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -460,12 +460,11 @@ fn gain_row(s: &Sensor, y: usize, x0: usize, w: usize, k: usize) -> Option= m.plane.saturating_add(m.planes) || ay < m.top || ay >= m.bottom || !(ay - m.top).is_multiple_of(m.row_pitch) { continue; } - let row = m.row(rv, k - m.plane); let o = out.get_or_insert_with(|| vec![1.0; w]); for (i, f) in o.iter_mut().enumerate() { let x = x0 + i; if x >= a.x && m.covers(x - a.x, ay) { - *f *= m.at(&row, ((x - a.x) as f64 + 0.5) / a.width as f64); + *f *= m.at_rv(rv, k - m.plane, ((x - a.x) as f64 + 0.5) / a.width as f64); } } } diff --git a/crates/raw/src/ljpeg.rs b/crates/raw/src/ljpeg.rs index e9c42bdeb4..4ab32df8b8 100644 --- a/crates/raw/src/ljpeg.rs +++ b/crates/raw/src/ljpeg.rs @@ -299,6 +299,11 @@ fn parse(data: &[u8]) -> Result> { let mut pos = 2; let mut frame: Option bool { /// eyedropper like Photoshop's. While armed, the next click on the image writes the merged /// composite colour into the `color` field (the same sampler the Color Picker uses). const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; -/// What OK on an edited smart filter runs (`smart_ui::confirm_command`); its preview too. +/// What OK on an edited smart filter runs (`smart_ui::confirm_command`). const SMART_FILTER_SET_PARAMS: &str = "layer.smartFilter.setParams"; const COLOR_EYEDROPPER: &str = "__colorEyedropper"; @@ -772,6 +792,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { crate::select_mask_view::controls(ui, f); } for p in parse_spec(&spec) { + if zoom_center::owns(&cmd, f) && matches!(p.key.as_str(), "centerX" | "centerY") { + if p.key == "centerX" { + zoom_center::row(ui, f); + } + continue; + } match p.kind { Kind::Range { min, max, default } => { let mut v = f.get(&p.key).and_then(Value::as_f64).unwrap_or(default as f64) as f32; @@ -885,6 +911,10 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { ui.horizontal(|ui| { ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); ui.add(egui::TextEdit::singleline(&mut v).desired_width(200.0)); + // Install Plug-in…: pick the .wasm file instead of typing its path (#7531). + if cmd == "plugin.install" && p.key == "path" && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() { + f.insert(crate::dialogs::BROWSE_FIELD.into(), json!(p.key)); + } }); if v.is_empty() { f.remove(&p.key); @@ -1003,6 +1033,45 @@ pub fn params_of(f: &Map) -> Value { Value::Object(f.iter().filter(|(k, v)| !k.starts_with("__") && !unset(k, v)).map(|(k, v)| (k.clone(), v.clone())).collect()) } +/// Filter › Distort › Displace confirmed with no map: no open document picked and no path typed +/// (#5806). Photoshop asks for the displacement map file after OK; so does PhotoCraft. +pub(crate) fn needs_displace_map(command: &str, params: &Value) -> bool { + command == "filter.distort.displace" + && params.get("mapPath").and_then(Value::as_str).is_none_or(|p| p.trim().is_empty()) + && params.get("mapDocument").and_then(Value::as_i64).is_none_or(|i| i < 0) +} + +/// Asks for the displacement map, then runs Displace with the dialog's values and that file, as +/// OK in a dialog that had one would (smart filters included). Cancel applies nothing. In the +/// browser the picker gives contents, not a path the filter can read: the map has to be opened +/// as a document and picked as Map Document there. It displaces the document that asked, even +/// when another one was activated while the picker was open. +pub(crate) fn ask_displace_map(app: &mut PhotocraftApp, command: String, fields: Map) -> Result { + use crate::file_dialog::{FileDialogAnswer, FileDialogRequest}; + let doc = app.active_doc_id()?; + let initial_dir = crate::file_dialog::last_used_dir(&app.ui.recent_files); + app.ask_file(FileDialogRequest::Open { multiple: false, initial_dir, extensions: None }, move |app, answer| { + let path = match answer { + FileDialogAnswer::Paths(paths) => paths.into_iter().next().ok_or("no displacement map was chosen")?, + FileDialogAnswer::Unavailable(e) => return Err(e), + FileDialogAnswer::Contents(..) | FileDialogAnswer::ContentsMany(_) => { + return Err("open the displacement map as a document, then choose it as the Map Document".into()); + } + FileDialogAnswer::SaveTo(_) | FileDialogAnswer::FolderTo(_) => return Err("unexpected answer from the file dialog".into()), + }; + let mut fields = fields; + fields.insert("mapPath".into(), json!(path)); + app.refocus(doc)?; + let (cmd, params) = crate::smart_ui::confirm_command(app, &fields, command, params_of(&fields))?; + let result = app.run(&cmd, params); + if result.is_ok() { + remember(app, &cmd, &fields); + let _ = crate::actions::resume(app, &fields); + } + result + }) +} + /// Compute a preview document: run `command` with `params` on the proxy (scaled) copy of `doc`. pub fn preview_document(doc: &Document, active: Option, command: &str, params: &Value, k: u32) -> Option { preview_document_with(doc, active, command, params, k, None) @@ -1051,23 +1120,87 @@ pub(crate) fn preview_document_for_target( return crate::select_mask_view::preview_document(doc, active, params, k, cancel); } // Warp Text renders the type again from the layer model (font size, position), which a - // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). So does - // a smart filter being edited: the smart object renders again from its source (#6324). - let (run_k, reduce_k) = if command == "type.warpText" || command == SMART_FILTER_SET_PARAMS { (1, k) } else { (k, 1) }; - let proxy = crate::proxy::proxy_document(doc, run_k); + // reduced proxy doesn't scale: it runs at full size and the result is reduced (#218). + let (run_k, reduce_k) = if command == "type.warpText" { (1, k) } else { (k, 1) }; + let mut proxy = crate::proxy::proxy_document(doc, run_k); + let mut p = params.clone(); + if is_smart_edit(command) { + let layer = match params.get("layer") { + Some(id) => photocraft_doc::LayerId(id.as_u64()?), + None => active?, + }; + let index = usize::try_from(params.get("index")?.as_u64()?).ok()?; + let photocraft_doc::LayerContent::Smart(sm) = &mut proxy.layer_mut(layer)?.content else { return None }; + // Proxy_document scales the cache, placement and shared mask (#5766). Editing a filter + // re-renders its source: scale every filter's lengths and the mask's feather as well. + if run_k > 1 { + if let Some(mask) = &mut sm.filter_mask { + mask.feather /= run_k as f32; + } + for f in &mut sm.smart_filters { + scale_preview_params(&f.command, &mut f.params, run_k); + } + if command == "layer.smartFilter.setParams" { + let f = sm.smart_filters.get(index)?; + scale_preview_params(&f.command, p.get_mut("params")?, run_k); + } + } + } else { + scale_preview_params(command, &mut p, run_k); + } let mut s = photocraft_engine::Session::new(); s.color.settings = color.clone(); s.set_inline_job_ctx(cancel.cloned()); let doc_id = proxy.id; s.add_document(proxy, None); - let mut p = params.clone(); + // Bitmap's Output resolution resamples the image: the preview keeps the canvas's size and + // resolution, so its dots and halftone screen show at their printed size (#5559). + if command == crate::bitmap_dialog::COMMAND + && let Some(o) = p.as_object_mut() + { + o.remove("resolution"); + } add_source_documents(&mut s, doc_id, command, &mut p, documents, run_k); if let Some(id) = active { s.select_layer(id).ok()?; } s.active_mut()?.channel_view.target = target; - if run_k > 1 - && let Some(o) = p.as_object_mut() + s.execute(command, p).ok()?; + s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) +} + +/// Whether `command` is a Smart Filter edit (its parameters or its blending) rather than a filter run on its own. +fn is_smart_edit(command: &str) -> bool { + matches!(command, "layer.smartFilter.setParams" | "layer.smartFilter.blendingOptions") +} + +/// Each stage constrains the proxy factor; an unscalable filter keeps full-resolution preview. +pub(crate) fn smart_preview_factor(doc: &Document, command: &str, params: &Value, k: u32) -> u32 { + if !is_smart_edit(command) { + return preview_factor(command, params, k); + } + let Some(layer) = params.get("layer").and_then(Value::as_u64).and_then(|id| doc.layer(photocraft_doc::LayerId(id))) else { return 1 }; + let photocraft_doc::LayerContent::Smart(sm) = &layer.content else { return 1 }; + let mut factor = sm.smart_filters.iter().fold(k, |k, f| preview_factor(&f.command, &f.params, k)); + if command == "layer.smartFilter.setParams" + && let Some(index) = params.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()) + && let Some(f) = sm.smart_filters.get(index) + && let Some(p) = params.get("params") + { + factor = preview_factor(&f.command, p, factor); + } + factor +} + +/// Scale the pixel-sized parameters of `command` down by the preview proxy factor `k`, keeping each above its minimum and negative lengths negative. +fn scale_preview_params(command: &str, params: &mut Value, k: u32) { + if command == photocraft_engine::plugin_cmds::RUN { + // A plug-in declares its pixel lengths (`"unit": "px"` in its manifest), and only those + // are scaled: a 12 px shift previewed 4 times too wide at k = 4, and a unitless param that + // happens to be called `size` or `radius` must not be divided (#7524). + photocraft_engine::plugin_cmds::scale_for_preview(params, k); + } else if k > 1 + && let Some(o) = params.as_object_mut() { let spec = photocraft_engine::commands::find(command).map(|c| parse_spec(c.params)).unwrap_or_default(); for (key, v) in o.iter_mut() { @@ -1083,17 +1216,15 @@ pub(crate) fn preview_document_for_target( let floor = minimum.unwrap_or(0.0).max(if key == "cellSize" { 1.0 } else { 0.1 }); // Negative lengths (Offset's left/up shift, Displace's inverted scale) keep their // sign: the floor that keeps a radius positive turned them into +0.1 (#2063). - let x = x / run_k as f64; + let x = x / k as f64; *v = json!(if x > 0.0 { x.max(floor) } else { minimum.map_or(x, |m| x.max(m)) }); } } } // Blur Gallery pins carry their blur in document pixels too. if command.starts_with("filter.blurGallery.") { - crate::blur_pins::scale_for_proxy(&mut p, run_k); + crate::blur_pins::scale_for_proxy(params, k); } - s.execute(command, p).ok()?; - s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k)) } /// The preview session holds only the proxy of the edited document (index 0), so a document param @@ -1104,11 +1235,10 @@ fn add_source_documents(s: &mut photocraft_engine::Session, doc_id: photocraft_d let Some(spec) = photocraft_engine::commands::find(command) else { return }; let Some(o) = p.as_object_mut() else { return }; let mut added: Vec<(usize, usize)> = Vec::new(); - for param in parse_spec(spec.params).into_iter().filter(|p| p.kind == Kind::Document) { - let Some(v) = o.get_mut(¶m.key) else { continue }; - let Some(i) = v.as_u64().and_then(|i| usize::try_from(i).ok()) else { continue }; + let mut remap = |v: &mut Value| { + let Some(i) = v.as_u64().and_then(|i| usize::try_from(i).ok()) else { return }; // An index past the open documents stays as given, so the preview fails like OK would. - let Some(src) = documents.get(i) else { continue }; + let Some(src) = documents.get(i) else { return }; let at = if src.id == doc_id { 0 } else if let Some(&(_, at)) = added.iter().find(|(j, _)| *j == i) { @@ -1119,6 +1249,18 @@ fn add_source_documents(s: &mut photocraft_engine::Session, doc_id: photocraft_d at }; *v = json!(at); + }; + for param in parse_spec(spec.params).into_iter().filter(|p| p.kind == Kind::Document) { + if let Some(v) = o.get_mut(¶m.key) { + remap(v); + } + } + // A smart filter being edited carries its filter's params one level down: Displace's + // `mapDocument` there names an open document too. + if command == SMART_FILTER_SET_PARAMS + && let Some(v) = o.get_mut("params").and_then(Value::as_object_mut).and_then(|f| f.get_mut("mapDocument")) + { + remap(v); } // Adding a document makes it active: the command still runs on the edited one. s.set_active(0); @@ -1176,6 +1318,28 @@ impl FilterPreviewKey { mod tests { use super::*; + #[test] + fn a_unit_after_a_range_bound_does_not_reset_the_range_to_its_fallback() { + // #7598: `0.5..5000 px?` fell back to a maximum of 100, so the Mixer Brush's Size slider + // (and every range documented with a px or % suffix) topped out at 100. + let range = |spec: &str| match parse_spec(spec).into_iter().next().map(|p| p.kind) { + Some(Kind::Range { min, max, default }) => (min, max, default), + other => panic!("{spec}: {other:?}"), + }; + assert_eq!(range(r#"{"size":0.5..5000 px?}"#), (0.5, 5000.0, 0.5)); + assert_eq!(range(r#"{"scale":1..1000 %=100}"#), (1.0, 1000.0, 100.0)); + assert_eq!(range(r#"{"tolerance":0.5..10 px=2}"#), (0.5, 10.0, 2.0)); + assert_eq!(range(r#"{"angle":-180..180}"#), (-180.0, 180.0, -180.0)); + assert_eq!(range(r#"{"radius":0.1..1000=1}"#), (0.1, 1000.0, 1.0)); + assert_eq!(range(r#"{"gain":1e-3..2e2}"#), (0.001, 200.0, 0.001)); + // A bound that is no number at all still takes the old fallback rather than failing. + assert_eq!(range(r#"{"x":0..many}"#), (0.0, 100.0, 0.0)); + // The documented specs of the real commands read their full ranges. + let spec = photocraft_engine::commands::find("paint.pencil").expect("paint.pencil is registered"); + let size = parse_spec(spec.params).into_iter().find(|p| p.key == "size"); + assert!(matches!(size.map(|p| p.kind), Some(Kind::Range { max, .. }) if max == 5000.0)); + } + #[test] fn color_settings_working_spaces_list_installed_profiles_of_their_space() { // #6323: a press condition installed in the system can be the CMYK working space, as in @@ -1338,6 +1502,51 @@ mod tests { assert!(app.ui.dialogs.iter().any(|d| d.id == id)); } + /// #5806: OK in Displace with no map asks for the map file, as Photoshop does, and displaces + /// with the chosen file; Cancel in the picker applies nothing; a map already given runs at once. + #[test] + fn displace_asks_for_its_map_after_ok() { + let dir = std::env::temp_dir().join(format!("photocraft-displace-map-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let map = dir.join("map.png"); + let mut m = photocraft_engine::Session::new(); + m.execute("file.new", json!({"width": 16, "height": 16, "background": "white"})).unwrap(); + std::fs::write(&map, photocraft_io::export(&m.active().unwrap().doc, "map.png", &Default::default()).unwrap().bytes).unwrap(); + let answers = vec![Some(crate::file_dialog::FileDialogAnswer::Paths(vec![map.to_string_lossy().into_owned()])), None]; + let (show, asked) = crate::file_dialog::fake(answers); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services { file_dialog: Some(show), ..Default::default() }); + app.run("file.new", json!({"width": 16, "height": 16, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let ctx = egui::Context::default(); + let id = open(&mut app, "filter.distort.displace").unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.file_dialog_open(), "OK leaves the map picker pending"); + // Another document activated while the picker is open is left alone. + app.run("file.new", json!({"width": 16, "height": 16, "background": "white"})).unwrap(); + let other_steps = app.session.documents()[1].history.past_len(); + // The frame shows the picker (the service is asked then) and acts on its answer. + app.poll_file_dialog(&ctx, None); + assert_eq!(asked.borrow().len(), 1, "OK asks for the map"); + assert_eq!(app.session.active_index(), Some(0), "the document that asked is shown"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "Displace ran with the chosen map"); + assert_eq!(app.session.documents()[1].history.past_len(), other_steps, "not on the other document"); + // Cancelled: nothing is applied. + let id = open(&mut app, "filter.distort.displace").unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(asked.borrow().len(), 2); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + // A map given in the dialog runs without asking. + assert!(!needs_displace_map("filter.distort.displace", &json!({"mapPath": "/maps/x.psd"}))); + assert!(!needs_displace_map("filter.distort.displace", &json!({"mapDocument": 0}))); + assert!(needs_displace_map("filter.distort.displace", &json!({"mapDocument": -1, "mapPath": " "}))); + assert!(!needs_displace_map("filter.blur.gaussianBlur", &json!({}))); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn remembering_dialogs_never_persists_paths_or_document_indices() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -1699,6 +1908,12 @@ mod tests { assert!(missing.is_empty(), "missing Spanish dynamic labels: {missing:#?}"); } + #[test] + fn russian_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ru-RU").unwrap()); + assert!(missing.is_empty(), "missing Russian dynamic labels: {missing:#?}"); + } + /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their /// params, choices and title in every UI language, not just Korean. #[test] @@ -1758,6 +1973,52 @@ mod tests { assert_eq!(label("wavelengthMin"), "Wavelength Min"); } + /// #7423 (review on #7799): re-editing a Displace smart filter and picking another open + /// document as its map previews with that document, as OK applies it. The preview session held + /// only the edited document, and the map's `mapDocument` sits inside the filter's `params`. + #[test] + fn smart_displace_preview_uses_the_map_document() { + let mut s = photocraft_engine::Session::new(); + // Documents 0 and 1: a white and a black map, opened before the edited one. + 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(); + s.execute("filter.distort.displace", json!({"horizontal": 10, "vertical": 0, "mapDocument": 0})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer; + let documents: Vec<_> = s.documents().iter().map(|d| d.doc.clone()).collect(); + for map in [1, 0] { + let p = json!({"index": 0, "params": {"mapDocument": map}}); + let mut applied = photocraft_engine::Session::new(); + for d in &documents { + applied.add_document((**d).clone(), None); + } + applied.execute(SMART_FILTER_SET_PARAMS, p.clone()).unwrap(); + let want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).px; + let preview = preview_document_for_target( + &doc, + layer, + SMART_FILTER_SET_PARAMS, + &p, + 1, + None, + photocraft_engine::channel_cmds::ChannelTarget::Composite, + &documents, + &ColorSettings::default(), + ) + .unwrap_or_else(|| panic!("map {map}: no preview")); + assert!(photocraft_compose::render(&preview, doc.bounds()).px == want, "map {map}: the preview differs from what OK applies"); + } + } + /// Match Color's preview reads the Source document from the open documents, as OK does: the /// preview session held only the edited document, so a Source showed no change (#4851). #[test] @@ -2186,12 +2447,24 @@ mod tests { _ => panic!("a smart object"), }; let k = 4; - let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", &json!({"points": [[0, 20], [90, 150], [255, 230]]}), k).unwrap(); + let params = json!({"points": [[0, 20], [90, 150], [255, 230]]}); + let shown = preview_document(&doc, Some(layer), "image.adjustments.curves", ¶ms, k).unwrap(); + let full = preview_document(&doc, Some(layer), "image.adjustments.curves", ¶ms, 1).unwrap(); + let photocraft_doc::LayerContent::Smart(full_sm) = &full.layer(layer).unwrap().content else { panic!("a smart object") }; + let reference = photocraft_algo::resample::resize_surface(full_sm.cache.as_ref().unwrap(), 0.25, 0.25, photocraft_algo::resample::Resample::Bicubic); let photocraft_doc::LayerContent::Smart(sm) = &shown.layer(layer).unwrap().content else { panic!("a smart object") }; assert_eq!(sm.smart_filters.len(), 1, "the preview adds the Curves smart filter"); assert_eq!(shown.size, crate::proxy::proxy_document(&doc, k).size); // The 16 px square at (16, 16) is a 4 px square at (4, 4) on the proxy, not 16 px at (16, 16). - // Its corners are partly covered by the smart object's resampling (220/255), its inside fully. + // Bicubic reduction blends the square's boundary with transparency; compare all alpha + // values with a reduced full-size result, and require opacity only in the interior. + for y in 0..shown.size.height as i32 { + for x in 0..shown.size.width as i32 { + let expected = reference.pixel(x, y).last().copied().unwrap(); + let actual = alpha(&shown, x, y); + assert!((actual - expected).abs() <= 1.0 / 255.0, "({x}, {y}): alpha {actual}, expected {expected}"); + } + } for (x, y) in [(5, 5), (5, 6), (6, 6)] { assert!(alpha(&shown, x, y) > 0.99, "({x}, {y}) is inside the reduced square"); } diff --git a/crates/ui-egui/src/filter_dialog/zoom_center.rs b/crates/ui-egui/src/filter_dialog/zoom_center.rs new file mode 100644 index 0000000000..3acb09b052 --- /dev/null +++ b/crates/ui-egui/src/filter_dialog/zoom_center.rs @@ -0,0 +1,101 @@ +//! Radial Blur's Zoom centre: a schematic of the streaks beside the two coordinate sliders. +//! The diagram and sliders edit the same dialog fields used by preview, OK and automation. + +use egui::{Rect, Response, Sense, Stroke, Ui, Vec2, pos2, vec2}; +use serde_json::{Map, Value, json}; + +use crate::theme::Tokens; + +pub(super) fn owns(command: &str, fields: &Map) -> bool { + command == "filter.blur.radialBlur" && fields.get("method").and_then(Value::as_str) == Some("zoom") +} + +fn number(fields: &Map, key: &str, default: f64, min: f64, max: f64) -> f32 { + fields.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(default).clamp(min, max) as f32 +} + +fn rounded(value: f32) -> f32 { + (value.clamp(0.0, 1.0) * 100.0).round() / 100.0 +} + +pub(super) fn row(ui: &mut Ui, fields: &mut Map) { + let mut center = vec2(number(fields, "centerX", 0.5, 0.0, 1.0), number(fields, "centerY", 0.5, 0.0, 1.0)); + let amount = number(fields, "amount", 10.0, 1.0, 100.0); + ui.horizontal(|ui| { + let (rect, mut response) = ui.allocate_exact_size(vec2(112.0, 112.0), Sense::click_and_drag()); + // Leave room for the centre marker at 0 and 1, including all four corners. + let plot = rect.shrink(4.0); + interact(ui, &mut response, plot, &mut center); + ui.add_space(4.0); + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + for (key, value) in [("centerX", &mut center.x), ("centerY", &mut center.y)] { + let label = super::label(key); + let gestures = crate::widgets::RowGestures { reset: Some(0.5), wheel_step: None, ..Default::default() }; + let slider = crate::widgets::slider_row_with(ui, &label, value, 0.0..=1.0, "", None, gestures); + *value = rounded(*value); + slider.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), &label)); + } + }); + // Paint after both sliders so their edits move the marker in this very frame. + paint(ui, rect, plot, center, amount, response.has_focus()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), tl!("Center"))); + response.on_hover_cursor(egui::CursorIcon::Crosshair); + }); + for (key, value) in [("centerX", center.x), ("centerY", center.y)] { + fields.insert(key.into(), json!((f64::from(value) * 100.0).round() / 100.0)); + } +} + +fn interact(ui: &Ui, response: &mut Response, plot: Rect, center: &mut Vec2) { + ui.memory_mut(|m| m.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })); + let primary = egui::PointerButton::Primary; + if (response.clicked_by(primary) || response.dragged_by(primary) || (response.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down()))) + && let Some(p) = response.interact_pointer_pos().filter(|p| p.x.is_finite() && p.y.is_finite()) + { + *center = vec2(rounded((p.x - plot.left()) / plot.width()), rounded((p.y - plot.top()) / plot.height())); + response.request_focus(); + response.mark_changed(); + } + if response.has_focus() { + let step = ui.input(|i| { + vec2( + i.num_presses(egui::Key::ArrowRight) as f32 - i.num_presses(egui::Key::ArrowLeft) as f32, + i.num_presses(egui::Key::ArrowDown) as f32 - i.num_presses(egui::Key::ArrowUp) as f32, + ) + }); + if step != Vec2::ZERO { + *center = vec2(rounded(center.x + step.x * 0.01), rounded(center.y + step.y * 0.01)); + response.mark_changed(); + } + } +} + +fn paint(ui: &Ui, rect: Rect, plot: Rect, center: Vec2, amount: f32, focused: bool) { + if !ui.is_rect_visible(rect) { + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(rect, t.radius_sm, t.field); + let painter = ui.painter_at(rect.shrink(1.0)); + let origin = plot.min + center * plot.size(); + // A regular grid of points stretches away from the origin. The Zoom kernel samples + // inward by amount/200, so a source point's outermost image is at 1/(1 - amount/200). + // This small fixed diagram never filters or reads the document's pixels. + let stretch = 1.0 / (1.0 - amount / 200.0); + for y in 0..9 { + for x in 0..9 { + let point = plot.min + vec2((x as f32 + 0.5) / 9.0, (y as f32 + 0.5) / 9.0) * plot.size(); + let end = origin + (point - origin) * stretch; + for step in 0..4 { + let from = point.lerp(end, step as f32 / 4.0); + let to = point.lerp(end, (step + 1) as f32 / 4.0); + painter.line_segment([from, to], Stroke::new(1.0, t.text_dim.gamma_multiply(1.0 - step as f32 / 5.0))); + } + } + } + painter.line_segment([pos2(plot.left(), origin.y), pos2(plot.right(), origin.y)], Stroke::new(1.0, t.field_border)); + painter.line_segment([pos2(origin.x, plot.top()), pos2(origin.x, plot.bottom())], Stroke::new(1.0, t.field_border)); + painter.circle(origin, 3.0, t.field, Stroke::new(1.5, t.accent)); + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, if focused { t.accent } else { t.field_border }), egui::StrokeKind::Inside); +} diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index b068f3339d..0dd76f4f45 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -234,7 +234,7 @@ fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) let (width, height) = (doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); if let Some(st) = app.session.active() && let Some(pixels) = - crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, app.session.prefs().interface.show_channels_in_color, st.active_layer) + crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, crate::channel_view::Look::of(&app.session), st.active_layer) { let image = egui::ColorImage::new([width as usize, height as usize], pixels); return ctx.load_texture(name, image, egui::TextureOptions::LINEAR); @@ -342,7 +342,9 @@ mod tests { let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; let pixels = image.pixels.clone(); delta.clear(); - let expected = crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, false, st.active_layer).unwrap(); + let expected = + crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, crate::channel_view::Look::default(), st.active_layer) + .unwrap(); assert_eq!(pixels, expected, "depth {depth}, factor {k}"); assert_ne!(pixels[0], pixels[pixels.len() - 1], "the alpha edge is visible, not the white RGB layer"); assert!(pixels.iter().any(|p| p.r() > 0 && p.r() < 255), "the blurred edge is visible"); diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 3006323c20..0950e67939 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -95,8 +95,11 @@ impl Worker { /// Stop and release previews of closed dialogs even when their canvas is no longer being drawn. pub(crate) fn discard_closed(app: &mut crate::PhotocraftApp) { - let visible = - app.ui.dialogs.iter().any(|d| d.fields.contains_key("__filter") && d.fields.get("__preview").and_then(serde_json::Value::as_bool) == Some(true)); + let visible = app.ui.dialogs.iter().any(|d| { + d.fields.contains_key("__filter") + && d.fields.get("__preview").and_then(serde_json::Value::as_bool) == Some(true) + && d.fields.get("__smartFilter").is_none_or(|target| crate::smart_filter_dialog::validate_target(app, target).is_ok()) + }); if !visible { if let Some(r) = &app.filter_preview_worker.running { r.job.cancel(); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 76eb7a652d..a9d7f5a28f 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1136,6 +1136,14 @@ impl GpuInfo { let template = "Driver: {driver}"; crate::i18n::fmt(tl!(template), &[("driver", &or(&self.driver, "unknown"))]) }, + ]; + // The card's own memory caps the GPU budgets, so it explains a huge canvas falling back + // to the CPU. Only discrete cards report one (integrated GPUs share system memory). + if let Some(mb) = self.video_memory_mb { + let template = "Graphics memory: {size}"; + v.push(crate::i18n::fmt(tl!(template), &[("size", &crate::chrome_ui::fmt_bytes(mb.saturating_mul(1 << 20)))])); + } + v.extend([ { let template = "GPU backend preference: {backend}"; crate::i18n::fmt(tl!(template), &[("backend", &or(&self.preference, "auto"))]) @@ -1159,7 +1167,7 @@ impl GpuInfo { }; crate::i18n::fmt(tl!(template), &[("renderer", tl!(renderer))]) }, - ]; + ]); if let Some(f) = &self.fallback { v.push({ let template = "Fallback: {reason}"; @@ -1379,8 +1387,9 @@ impl LayerFit { /// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers. pub const MIN_GPU_BUDGET: u64 = 512 << 20; -/// Why a full refresh whose layer pages don't fit [`memory_budget`] goes to the CPU compositor. -pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU"; +/// Why a full refresh whose layer pages don't fit [`memory_budget`] (or GPU memory) goes to the +/// CPU compositor. +pub const OVER_BUDGET: &str = photocraft_gpu::OVER_BUDGET; /// GPU memory the wgpu compositor may hold for layer pages and effect maps. /// @@ -2792,6 +2801,19 @@ mod tests { assert_eq!(detect_video_memory(0xdead, 0xbeef), None, "no such adapter"); } + /// System Info names the card's own memory, after the driver, when the card reports one; an + /// integrated GPU or an unknown card shows no such line. + #[test] + fn system_info_shows_the_cards_memory() { + let info = GpuInfo { adapter: "Card".into(), driver: "Driver 1".into(), video_memory_mb: Some(8192), ..Default::default() }; + let lines = info.lines(); + assert_eq!(lines.get(2).map(String::as_str), Some("Driver: Driver 1")); + assert_eq!(lines.get(3).map(String::as_str), Some("Graphics memory: 8.0G")); + let none = GpuInfo { video_memory_mb: None, ..info }.lines(); + assert!(none.iter().all(|l| !l.starts_with("Graphics memory")), "{none:?}"); + assert_eq!(none.len() + 1, lines.len()); + } + /// The Linux reader finds the card by its PCI ids in a `/sys/class/drm`-shaped folder and reads /// amdgpu's VRAM size; a card without one, unreadable ids or a malformed size give nothing. #[test] diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index 337283bdf8..d9608a539b 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -1,6 +1,7 @@ //! GPU health in the shell (#243, #4): when the wgpu device is lost or reports an error, the //! GPU canvas is dropped and every document keeps drawing through the CPU compositor and egui -//! textures for the rest of the session, with a recovery warning. Also runs the desktop app's +//! textures, with a recovery warning. After an error Retry GPU builds a new GPU canvas on the same +//! device (#6577); after a loss the CPU canvas stays for the rest of the session. Also runs the desktop app's //! "started" hook once the first frames have rendered (the crash-safe startup marker), and //! provides the Help › System Info text (including the monitor profile in use). @@ -38,12 +39,103 @@ pub fn check(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// End-of-frame check of the device the window itself draws with (#3438). Once wgpu reports it +/// lost (a driver reset, for example after the PC slept), egui-wgpu can't draw another frame: one +/// with shapes panics creating its staging buffers (#1215), and one without leaves a frozen window +/// whose close button opens an unsaved-changes prompt nobody can see. So this frame and every later +/// one draw nothing; the first writes every unsaved document to crash recovery, waits for the +/// writes, and closes the app without asking once all of them are saved. The next launch reopens +/// the documents. If a write fails (a full disk, say) the app stays open with the documents in +/// memory and tries again every [`RECOVERY_RETRY_SECS`]: closing would lose them. +pub fn check_window(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some((device, health)) = app.window_device.as_ref() else { return }; + // A loss while the app sat idle or the PC slept is noticed here, before the next frame. + health.probe(device); + if !health.device_lost() { + return; + } + ctx.graphics_mut(|g| *g = Default::default()); + let now = ctx.input(|i| i.time); + if !app.window_lost { + app.window_lost = true; + let reason = health.fault().map_or_else(|| "GPU device lost".to_string(), |f| f.to_string()); + log::error!("{reason}: the window can't be drawn any more; saving unsaved documents for recovery and closing"); + } else { + match app.window_recovery_retry { + // Saved, and the close is on its way. + None => return, + Some(at) if now < at => { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((at - now).max(0.0))); + return; + } + Some(_) => {} + } + } + match save_for_recovery(app) { + Ok(()) => { + app.window_recovery_retry = None; + app.allow_close = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(e) => { + log::error!("{e}; keeping the documents open and trying again in {RECOVERY_RETRY_SECS} s"); + app.window_recovery_retry = Some(now + RECOVERY_RETRY_SECS); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(RECOVERY_RETRY_SECS)); + } + } +} + +/// Seconds between attempts to save unsaved work for recovery after the window's device was lost. +pub const RECOVERY_RETRY_SECS: f64 = 2.0; + +/// Write every document with unsaved changes to crash recovery, whatever the autosave timer and +/// preference, and wait until the writes finished (see `Services::flush_autosaves`). `Ok` only when +/// each of them is known to be on disk. +fn save_for_recovery(app: &mut PhotocraftApp) -> Result<(), String> { + let jobs: Vec<_> = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| (d.doc.clone(), d.revision, d.path.clone())).collect(); + if jobs.is_empty() { + return Ok(()); + } + // (document id, why) for each document not known to be saved. + let mut failed: Vec<(u64, String)> = Vec::new(); + let Some(save) = app.services.autosave.as_mut() else { return Err("no crash-recovery autosave".into()) }; + for (doc, revision, path) in &jobs { + if let Err(e) = save(doc, *revision, path.as_deref()) { + failed.push((doc.id.0, format!("{}: {e}", doc.name))); + } + } + // Without a flush, only a service whose `autosave` reports the finished write can confirm it. + let outcomes = match app.services.flush_autosaves.as_mut() { + Some(flush) => flush(), + None if app.services.autosave_results.is_none() => Vec::new(), + None => return Err("crash-recovery autosaves can't be waited for".into()), + }; + let confirm = app.services.flush_autosaves.is_some(); + for (doc, revision, _) in &jobs { + if failed.iter().any(|(id, _)| *id == doc.id.0) { + continue; + } + // The writer skips to the newest queued snapshot, so a later revision confirms this one. + let mine: Vec<_> = outcomes.iter().filter(|(id, written, _)| *id == doc.id.0 && written >= revision).collect(); + if let Some((.., Err(e))) = mine.iter().find(|(.., outcome)| outcome.is_err()) { + failed.push((doc.id.0, format!("{}: {e}", doc.name))); + } else if confirm && mine.is_empty() { + failed.push((doc.id.0, format!("{}: the write wasn't confirmed", doc.name))); + } else { + log::info!("{} (revision {revision}) saved for recovery", doc.name); + } + } + let failed: Vec<_> = failed.into_iter().map(|(_, why)| why).collect(); + if failed.is_empty() { Ok(()) } else { Err(format!("couldn't save for recovery: {}", failed.join("; "))) } +} + /// Drop the GPU canvas after `fault`: free its resources, send every view through the CPU /// path, record why, and tell the user. Documents are untouched. pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { let Some(gpu) = app.gpu.take() else { return }; - log::error!("{fault}; using the CPU renderer for the rest of the session"); + log::error!("{fault}; using the CPU renderer"); gpu.release(); + app.gpu_fault = Some(fault.clone()); // Canvas caches pointed at GPU textures: rebuild them as egui textures. app.canvases.clear(); app.proxy_uploaded = None; @@ -94,7 +186,35 @@ pub fn queue_fallback_notice(app: &mut PhotocraftApp, reason: impl Into) app.ui.gpu_fallback_notice = Some(reason.into()); } -/// Store the next-launch choice without restarting or risking unsaved documents. +/// Whether Retry GPU can build a new GPU canvas now: the canvas was dropped after an error (out +/// of memory, validation) on a device that still works. After a loss, or a GPU start that +/// failed, only a restart can try the GPU again. +fn can_retry_now(app: &PhotocraftApp) -> bool { + app.gpu_render_state.is_some() && app.gpu_fault.as_ref().is_some_and(|f| !f.is_lost()) +} + +/// Build a new GPU canvas on the device the dropped one used, with a fresh health flag, and send +/// the views back through it (#6577). Returns whether the canvas is active again. +fn retry_now(app: &mut PhotocraftApp) -> bool { + let Some(rs) = app.gpu_render_state.clone() else { return false }; + app.set_wgpu(rs); + if !app.gpu_active() { + return false; + } + log::info!("GPU canvas rebuilt after {}", app.gpu_fault.as_ref().map_or_else(String::new, ToString::to_string)); + app.gpu_fault = None; + // The CPU canvas's egui textures: rebuild every view on the GPU. + app.canvases.clear(); + app.proxy_uploaded = None; + app.perf.gpu_fallback = None; + app.perf.gpu_info.lost = None; + app.ui.status = tl!("GPU acceleration resumed.").to_string(); + app.ui.status_error = false; + true +} + +/// Store the next-launch choice without restarting or risking unsaved documents. Retry GPU after +/// an error on a working device also builds a new GPU canvas at once (see [`retry_now`]). fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> { app.run( "prefs.set", @@ -106,6 +226,9 @@ fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> { )?; crate::prefs_ui::save_preferences(app)?; app.ui.gpu_fallback_notice = None; + if retry && can_retry_now(app) && retry_now(app) { + return Ok(()); + } if retry { crate::notices::post( app, @@ -121,6 +244,7 @@ fn choose_recovery(app: &mut PhotocraftApp, retry: bool) -> Result<(), String> { /// Recovery is an explicit choice; the CPU document renderer stays active while the user works. pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(reason) = app.ui.gpu_fallback_notice.clone() else { return }; + let retry_now = can_retry_now(app); let t = crate::theme::Tokens::get(ctx); let mut choice = None; egui::Window::new(tl!("Switched to CPU rendering")) @@ -135,7 +259,11 @@ pub fn show_fallback(app: &mut PhotocraftApp, ctx: &egui::Context) { ui.collapsing(tl!("Details"), |ui| { ui.label(&reason); }); - ui.label(tl!("Retry GPU requires a restart. Save your work first.")); + if retry_now { + ui.label(tl!("Retry GPU tries the graphics processor again now.")); + } else { + ui.label(tl!("Retry GPU requires a restart. Save your work first.")); + } ui.add_space(8.0); ui.horizontal(|ui| { use crate::widgets::{ButtonRole, DialogButton}; @@ -237,4 +365,42 @@ mod tests { crate::prefs_ui::tick(&mut app, &egui::Context::default()); assert_eq!(saves.get(), 2, "retry choice writes once"); } + + /// #6577: after a GPU error on a device that still works (out of memory, validation), Retry + /// GPU builds a new GPU canvas at once, without a restart or closing documents. After a loss + /// the device can't be used again, so the retry still waits for the next launch. + #[test] + fn retry_gpu_after_an_error_rebuilds_the_canvas_without_a_restart() { + let _gpu = photocraft_testkit::gpu_lock(); + let rs = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())); + let Ok(rs) = rs else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let services = crate::Services { save_prefs: Some(Box::new(|_| Ok(()))), ..Default::default() }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + app.set_wgpu(rs); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let ctx = egui::Context::default(); + // An error on a live device: Retry GPU rebuilds the canvas now. + app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Error("Out of Memory".into())); + check(&mut app, &ctx); + assert!(!app.gpu_active() && app.ui.gpu_fallback_notice.is_some()); + assert!(can_retry_now(&app)); + choose_recovery(&mut app, true).unwrap(); + assert!(app.gpu_active(), "Retry GPU rebuilt the GPU canvas"); + assert!(app.gpu_health().unwrap().is_ok(), "with a fresh health flag"); + assert_eq!(app.perf.gpu_info.canvas, "gpu"); + assert_eq!((app.perf.gpu_info.lost.as_deref(), app.perf.gpu_fallback.as_deref()), (None, None)); + assert_eq!(app.ui.status, "GPU acceleration resumed."); + assert!(app.ui.gpu_fallback_notice.is_none() && app.ui.notices.is_empty(), "no restart notice"); + assert_eq!(app.session.documents().len(), 1, "the document stayed open"); + // A loss: the device can't be reused, so the retry is scheduled for the next launch. + app.gpu_health().unwrap().mark(photocraft_gpu::Fault::Lost("simulated".into())); + check(&mut app, &ctx); + assert!(!can_retry_now(&app)); + choose_recovery(&mut app, true).unwrap(); + assert!(!app.gpu_active()); + assert_eq!(app.ui.notices.len(), 1, "the restart notice"); + } } diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs new file mode 100644 index 0000000000..cf95f18a01 --- /dev/null +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -0,0 +1,208 @@ +//! The History panel's own items at the top of its panel menu (☰, #2966), in Photoshop's order: +//! Step Forward, Step Backward, New Snapshot…, Delete, Clear History, New Document, History +//! Options…, Close. Every item runs an engine command (or the dock's `window.panel.close`); the +//! dock appends its group items (Close Tab Group …) after them. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; + +/// The menu's items: label and command (None = shown greyed; not built yet). +pub const ITEMS: &[(&str, Option<&str>)] = &[ + ("Step Forward", Some("history.stepForward")), + ("Step Backward", Some("history.stepBackward")), + ("---", None), + // Takes a Full Document snapshot at once; switches to the New Snapshot dialog when that + // lands (#3567). + ("New Snapshot…", Some("history.newSnapshot")), + ("Delete", Some("history.deleteState")), + ("Clear History", Some("history.clear")), + ("New Document", Some("history.newDocument")), + ("---", None), + // The History Options dialog is not built yet. + ("History Options…", None), + ("---", None), + ("Close", Some("window.panel.close")), +]; + +fn params(cmd: &str) -> Value { + if cmd == "window.panel.close" { json!({"panel": "history"}) } else { json!({}) } +} + +/// The command and params an item runs: with a snapshot selected in the panel, Delete removes +/// that snapshot, as the panel's trash does (#5324); otherwise it removes the current state. +fn resolve(cmd: &str, snapshot: Option) -> (&str, Value) { + match snapshot { + Some(id) if cmd == "history.deleteState" => ("history.deleteSnapshot", json!({"snapshot": id})), + _ => (cmd, params(cmd)), + } +} + +fn enabled(app: &PhotocraftApp, cmd: &str) -> bool { + if cmd == "window.panel.close" { + return !app.session.prefs().workspace_locked; + } + // An open Free Transform owns the history (see `panels::history`). + app.ui.transform.is_none() && app.session.is_enabled(cmd) +} + +/// Draw the History items of the panel menu and run the one clicked. +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let snapshot = app.session.active().and_then(|st| crate::panels::selected_snapshot(ui.ctx(), st)); + let mut clicked: Option<&str> = None; + for &(label, cmd) in ITEMS { + if label == "---" { + ui.separator(); + continue; + } + let on = cmd.is_some_and(|c| enabled(app, resolve(c, snapshot).0)); + if ui.add_enabled(on, egui::Button::new(crate::i18n::t(label))).clicked() { + clicked = cmd; + } + } + let Some(cmd) = clicked else { return }; + ui.close(); + let (cmd, params) = resolve(cmd, snapshot); + if let Err(e) = app.run(cmd, params) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app + } + + fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel_menu(app, ui), app); + h.run_steps(2); + h + } + + fn states(h: &Harness<'static, PhotocraftApp>) -> (usize, usize) { + let st = h.state().session.active().unwrap(); + (st.history.entries().len(), st.history.redo_labels().count()) + } + + fn click(h: &mut Harness<'static, PhotocraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(2); + } + + fn is_disabled(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_label(label).accesskit_node().is_disabled() + } + + #[test] + fn menu_items_run_their_commands() { + let mut h = harness(app()); + assert_eq!(states(&h), (4, 0)); + assert!(is_disabled(&h, "Step Forward") && !is_disabled(&h, "Step Backward")); + assert!(is_disabled(&h, "History Options…"), "not built yet: greyed"); + assert!(!is_disabled(&h, "New Snapshot…")); + click(&mut h, "Step Backward"); + assert_eq!(states(&h), (3, 1)); + assert!(!is_disabled(&h, "Step Forward")); + click(&mut h, "Step Forward"); + assert_eq!(states(&h), (4, 0)); + click(&mut h, "Delete"); + assert_eq!(states(&h), (3, 0), "Delete removes the current state"); + let doc = h.state().session.active().unwrap().doc.clone(); + click(&mut h, "Clear History"); + assert_eq!(states(&h), (1, 0), "only the current state is left"); + assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &doc), "the image is unchanged"); + assert!(is_disabled(&h, "Clear History") && is_disabled(&h, "Delete") && is_disabled(&h, "Step Forward")); + click(&mut h, "New Document"); + assert_eq!(h.state().session.documents().len(), 2); + assert!(!h.state().ui.status_error); + } + + #[test] + fn close_hides_the_history_panel() { + let mut app = app(); + app.run("window.panel.activate", json!({"panel": "history"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"history".to_string())); + let mut h = harness(app); + click(&mut h, "Close"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(!layout.contains(&"history".to_string()), "the History panel is closed"); + } + + #[test] + fn items_wait_for_an_open_transform() { + let mut app = app(); + app.run("select.rect", json!({"x": 4, "y": 4, "width": 20, "height": 20})).unwrap(); + let mut h = harness(app); + let ctx = h.ctx.clone(); + crate::transform_tool::begin(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + for label in ["Step Backward", "New Snapshot…", "Delete", "Clear History", "New Document"] { + assert!(is_disabled(&h, label), "{label}"); + } + } + + #[test] + fn new_snapshot_adds_a_snapshot_and_leaves_the_states() { + let mut h = harness(app()); + let snaps = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().snapshots.len(); + let (before, steps) = (snaps(&h), states(&h)); + click(&mut h, "New Snapshot…"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(snaps(&h), before + 1); + assert_eq!(states(&h), steps, "a snapshot is no history step"); + } + + /// With a snapshot selected in the panel, Delete removes the snapshot and leaves the states, + /// as the panel's trash does (#5324); without one it removes the current state. + #[test] + fn delete_removes_the_selected_snapshot() { + let mut app = app(); + app.run("history.newSnapshot", json!({})).unwrap(); + let mut h = harness(app); + let snaps = |h: &Harness<'static, PhotocraftApp>| h.state().session.active().unwrap().snapshots.len(); + let before = snaps(&h); + let id = h.state().session.active().unwrap().snapshots.last().unwrap().id; + let ctx = h.ctx.clone(); + crate::panels::select_snapshot(&ctx, h.state().session.active().unwrap(), id); + h.run_steps(2); + let steps = states(&h); + click(&mut h, "Delete"); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); + assert_eq!(snaps(&h), before - 1, "Delete removed the selected snapshot"); + assert_eq!(states(&h), steps, "and left the states alone"); + click(&mut h, "Delete"); + assert_eq!(snaps(&h), before - 1, "no snapshot selected now: the snapshots are kept"); + assert_eq!(states(&h).0, steps.0 - 1, "the current state is deleted"); + } + + /// The labels aren't `tl!` literals, so the catalog scan can't see them: every language that + /// claims complete menus must still translate them. + #[test] + fn menu_labels_are_translated() { + for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) { + for &(label, _) in ITEMS.iter().filter(|(l, _)| *l != "---") { + assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); + } + } + } + + #[test] + fn without_a_document_only_close_is_enabled() { + let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); + for &(label, cmd) in ITEMS.iter().filter(|(l, _)| *l != "---") { + assert_eq!(is_disabled(&h, label), cmd != Some("window.panel.close"), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/history_panel_select_tests.rs b/crates/ui-egui/src/history_panel_select_tests.rs index 92c458e37f..14bfb344ca 100644 --- a/crates/ui-egui/src/history_panel_select_tests.rs +++ b/crates/ui-egui/src/history_panel_select_tests.rs @@ -58,3 +58,170 @@ fn clicking_a_state_jumps_there_with_one_command() { h.run_steps(2); assert_eq!(state(&h), (4, 0, rev + 2)); } + +#[test] +fn new_history_steps_scroll_the_current_state_into_view() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + for n in 0..18 { + app.run("layer.new.layer", json!({"name": format!("Layer {n}")})).unwrap(); + } + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(280.0, 120.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); + ready = true; + return; + } + history(app, ui); + }, + app, + ); + h.run_steps(4); + let row = h.get_all_by_label("New Layer").last().expect("the latest New Layer state").rect(); + let view = h.ctx.input(|i| i.viewport_rect()); + assert!(view.contains_rect(row), "the current history row must scroll into view: row {row:?} view {view:?}",); +} + +/// Open plus 18 New Layer states in a dock that shows only a few rows. +fn long_harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + for n in 0..18 { + app.run("layer.new.layer", json!({"name": format!("Layer {n}")})).unwrap(); + } + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(280.0, 220.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), theme::ThemeKind::ProMedium); + ready = true; + return; + } + history(app, ui); + }, + app, + ); + h.run_steps(4); + h +} + +/// Top of the `n`th New Layer row from the end (0 = the last). +fn row_top(h: &Harness<'_, PhotocraftApp>, n: usize) -> f32 { + let rows: Vec<_> = h.get_all_by_label("New Layer").map(|r| r.rect()).collect(); + rows[rows.len() - 1 - n].top() +} + +/// The rows' visible band: below the window top, above the footer. +fn shown(h: &Harness<'_, PhotocraftApp>, n: usize) -> bool { + let footer = h.get_by_label("Delete current state").rect().top(); + let top = row_top(h, n); + top >= h.ctx.input(|i| i.viewport_rect()).top() && top + 24.0 <= footer + 0.5 +} + +#[test] +fn clicking_a_visible_state_leaves_the_list_still() { + let mut h = long_harness(); + assert!(shown(&h, 0) && shown(&h, 1), "the newest states are in view"); + let before = row_top(&h, 1); + // A click on the state above the current one goes back one step; the row stays under the pointer. + let rows: Vec<_> = h.get_all_by_label("New Layer").collect(); + rows[rows.len() - 2].click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().history.redo_labels().count(), 1); + assert_eq!(row_top(&h, 1), before, "the clicked row must not move"); +} + +#[test] +fn undo_and_a_layer_click_keep_the_redo_state_in_view() { + let mut h = long_harness(); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(4); + assert!(shown(&h, 0), "the greyed redo state stays in view after Undo"); + let before = row_top(&h, 0); + // Selecting a layer bumps the revision but adds no state: the list stays where it is. + let id = h.state().session.active().unwrap().doc.layers[0].id; + h.state_mut().session.select_layer(id).unwrap(); + h.run_steps(4); + assert_eq!(row_top(&h, 0), before, "a layer click must not scroll History"); +} + +#[test] +fn scrolling_away_survives_a_change_that_adds_no_state() { + let mut h = long_harness(); + let view = h.ctx.input(|i| i.viewport_rect()); + h.event(egui::Event::PointerMoved(view.center())); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + } + h.run_steps(4); + let scrolled = row_top(&h, 0); + assert!(!shown(&h, 0), "the wheel scrolled the current state out of view"); + let id = h.state().session.active().unwrap().doc.layers[0].id; + h.state_mut().session.select_layer(id).unwrap(); + h.run_steps(4); + assert_eq!(row_top(&h, 0), scrolled, "the user's scroll position is kept"); + // A new state brings the current row back into view. + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.run_steps(4); + assert!(shown(&h, 0), "a new state scrolls into view"); +} + +#[test] +fn clicking_a_snapshot_keeps_it_in_view() { + let mut h = long_harness(); + h.state_mut().run("history.newSnapshot", json!({})).unwrap(); + h.run_steps(2); + // Scroll to the top, where the snapshots are. + let view = h.ctx.input(|i| i.viewport_rect()); + h.event(egui::Event::PointerMoved(view.center())); + for _ in 0..10 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 5.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + } + h.run_steps(4); + let before = h.get_all_by_label("Snapshot 1").next().expect("the snapshot row").rect().top(); + // Selecting it adds a "Snapshot 1" state at the bottom; the selected snapshot row must not scroll away. + h.get_all_by_label("Snapshot 1").next().expect("the snapshot row").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().history.entries().last().map(String::as_str), Some("Snapshot 1")); + assert_eq!(h.get_all_by_label("Snapshot 1").next().expect("the snapshot row").rect().top(), before, "the clicked snapshot stays put"); +} + +#[test] +fn a_new_state_at_the_history_limit_still_scrolls_into_view() { + let mut h = long_harness(); + // At the limit a new step drops the oldest: the count and the (same) name stay as they were. + let st = h.state_mut().session.active_mut().unwrap(); + st.history.max_states = st.history.past_len(); + let view = h.ctx.input(|i| i.viewport_rect()); + h.event(egui::Event::PointerMoved(view.center())); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + } + h.run_steps(4); + assert!(!shown(&h, 0), "the wheel scrolled the current state out of view"); + let past = h.state().session.active().unwrap().history.past_len(); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + assert_eq!(h.state().session.active().unwrap().history.past_len(), past, "the oldest state was dropped"); + h.run_steps(4); + assert!(shown(&h, 0), "a new state at the limit scrolls into view"); +} diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index 3f2845cae8..8b8bef092c 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -16,6 +16,8 @@ //! selection tools do their own ⌘ handling in `canvas` (⌘ inside the selection cuts the //! selected pixels, outside it moves the layer, #896), and the Hand, Zoom, Crop, Slice, Path //! Selection, shape, Pen and Type tools keep ⌘ for themselves. +//! - With the Hand tool selected, ⌘ (Ctrl off the Mac) is Zoom In while held and ⌥ (with or +//! without ⌘) Zoom Out ([`hand_zoom`], #6434). //! - `~` (the backtick key) with the Brush or Pencil: a stroke started while it is held erases, //! with that tool's own brush ([`erase_held`], #3439). //! @@ -118,6 +120,28 @@ fn cmd_move_held(app: &PhotocraftApp, i: &InputState) -> bool { i.modifiers.command && cmd_moves(app.ui.tool) && app.ui.transform.is_none() && app.ui.text_edit.is_none() } +/// The Hand tool's own modifiers (#6434): ⌘ (Ctrl off the Mac) held is Zoom In, ⌥ (with or +/// without ⌘) Zoom Out, as in Photoshop; the Modifier Keys panel's latched keys count. Not while +/// Free Transform is open (its box keeps ⌘ for the distort handles), and a modifier pressed during +/// a Hand press doesn't turn it into a zoom, release included: only a press made with it held +/// zooms (that one is latched by [`for_frame`] until the button comes up). A middle-button pan +/// stays a pan whatever is held. +fn hand_zoom(app: &PhotocraftApp, i: &InputState) -> Option { + let p = &i.pointer; + let hand_press = ((p.primary_down() || p.primary_released()) && !p.primary_pressed()) || p.middle_down(); + if app.ui.tool != Tool::Hand || app.ui.transform.is_some() || hand_press { + return None; + } + let m = crate::workspace_ui::sticky_mods(app, i.modifiers); + if m.alt { + Some(Temporary::ZoomOut) + } else if m.command { + Some(Temporary::ZoomIn) + } else { + None + } +} + /// Is `id` a held temporary tool rather than a command? pub fn is_temporary(id: &str) -> bool { photocraft_engine::prefs::TEMPORARY_TOOLS.iter().any(|t| t.0 == id) @@ -160,6 +184,7 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option let mut best: Option<(u8, Temporary, KeyboardShortcut)> = None; let mut cmd_move = false; let mut alt = false; + let mut hand = None; ctx.input(|i| { for t in Temporary::BOUND { if let Some(sc) = binding(app, t) @@ -171,12 +196,13 @@ pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option } cmd_move = cmd_move_held(app, i); alt = i.modifiers.alt; + hand = hand_zoom(app, i); }); // ⌥ turns a held Zoom In around, as it does the Zoom tool: ⌘⌥Space zooms out (and wins the // tie with ⌥Space), unless ⌥ is part of the Zoom In key itself. let held = best.map(|(_, t, sc)| if t == Temporary::ZoomIn && alt && !sc.modifiers.alt { Temporary::ZoomOut } else { t }); // A bound key (⌘Space is Zoom In) wins over ⌘ alone. - held.or(cmd_move.then_some(Temporary::Move)) + held.or(hand).or(cmd_move.then_some(Temporary::Move)) } /// The binding id of the Brush and Pencil erase key (`prefs::TEMPORARY_TOOLS`). diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index cfe89d4642..7251859a5e 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -180,12 +180,65 @@ fn space_is_a_temporary_hand_and_gives_the_tool_back() { assert!(!st.session.active().unwrap().history.can_undo(), "nothing was painted"); } +/// The open paths the last frame painted, as their screen points. +fn painted_paths(h: &Harness<'static, PhotocraftApp>) -> Vec> { + fn walk(s: &egui::Shape, out: &mut Vec>) { + match s { + egui::Shape::Path(p) if !p.closed => out.push(p.points.clone()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, &mut out); + } + out +} + +#[test] +fn the_pen_path_being_drawn_stays_visible_while_space_pans() { + // #6592: the path vanished for as long as the Hand dragged. + let mut h = harness(Tool::Pen); + click(&mut h, 100.0, 100.0, Modifiers::NONE); + click(&mut h, 200.0, 100.0, Modifiers::NONE); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + key(&mut h, Key::Space, true, Modifiers::NONE); + let before = h.state().ui.views[0].center; + press_at(&mut h, 150.0, 200.0); + move_to(&mut h, 190.0, 230.0); + assert_ne!(h.state().ui.views[0].center, before, "the view is panning"); + // Mid-drag: the segment is painted where its anchors now are on screen. + let (a, b) = (screen(&h, 100.0, 100.0), screen(&h, 200.0, 100.0)); + let near = |p: &Pos2, q: Pos2| p.distance(q) < 1.0; + assert!( + painted_paths(&h).iter().any(|pts| pts.first().is_some_and(|p| near(p, a)) && pts.last().is_some_and(|p| near(p, b))), + "the Pen path from {a:?} to {b:?} is drawn while panning" + ); + // The fist (#2196): the OS's own on macOS and Linux; on Windows our bitmap, over the + // `Crosshair` fallback `CursorLifecycle` leaves under it. + let out = &h.output().platform_output; + let fist = if cfg!(target_os = "windows") { + (egui::CursorIcon::Crosshair, crate::tool_cursor::hand_bitmap(&h.ctx, true).map(|i| i.rgba)) + } else { + (egui::CursorIcon::Grabbing, None) + }; + assert_eq!((out.cursor_icon, out.cursor_image.as_ref().map(|i| i.rgba.clone())), fist, "the fist while panning (#2196)"); + release(&mut h, 190.0, 230.0, Modifiers::NONE); + key(&mut h, Key::Space, false, Modifiers::NONE); + h.run_steps(2); + let st = h.state(); + assert_eq!(st.ui.pen.as_ref().map(|p| p.knots.len()), Some(2), "the path is still being drawn"); + assert_eq!(st.ui.tool, Tool::Pen); +} + #[test] fn space_while_editing_type_is_not_the_hand_tool() { let mut h = harness(Tool::Type); let id = h.state_mut().run("type.create", json!({"text": "ئا", "size": 20, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap(); h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 2, anchor: 2, session: "t".into(), @@ -643,7 +696,9 @@ fn cmd_is_not_the_move_tool_for_selection_hand_zoom_pen_shape_or_type_tools() { let mut h = harness(tool); h.event(Event::ModifiersChanged(platform(Modifiers::COMMAND))); h.run_steps(1); - assert_eq!(held_tool(h.state(), &h.ctx), None, "{tool:?}"); + // The Hand's own ⌘ is Zoom In (#6434). + let want = (tool == Tool::Hand).then_some(Temporary::ZoomIn); + assert_eq!(held_tool(h.state(), &h.ctx), want, "{tool:?}"); } for tool in [Tool::Brush, Tool::Eraser, Tool::Gradient, Tool::CloneStamp, Tool::Eyedropper, Tool::MagneticLasso, Tool::QuickSelection] { assert!(cmd_moves(tool), "{tool:?}"); @@ -796,3 +851,171 @@ fn a_held_key_keeps_a_pending_crop_and_another_tools_key_commits_it() { assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(200, 150)); assert_eq!(h.state().ui.crop_rect, None); } + +/// #6434: with the Hand tool selected, ⌘ (Ctrl off the Mac) held is Zoom In and ⌥ (with or +/// without ⌘) Zoom Out, as in Photoshop; letting go gives the Hand back. +#[test] +fn cmd_and_alt_with_the_hand_tool_zoom_in_and_out() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + + hold_mods(&mut h, Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + let z2 = h.state().ui.views[0].zoom; + assert!(z2 < z1, "{z1} -> {z2}"); + hold_mods(&mut h, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + click(&mut h, 200.0, 150.0, Modifiers::COMMAND | Modifiers::ALT); + assert!(h.state().ui.views[0].zoom < z2); + hold_mods(&mut h, Modifiers::NONE); + assert_eq!(held_tool(h.state(), &h.ctx), None); + assert_eq!(h.state().ui.tool, Tool::Hand, "the Hand is still the tool"); +} + +/// A ⌘-drag with the Hand is the Zoom tool's drag, not a pan: a scrubby zoom with Scrubby Zoom on +/// (the default). +#[test] +fn cmd_drag_with_the_hand_tool_is_a_scrubby_zoom() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + drag_with(&mut h, Modifiers::COMMAND, 200.0, 150.0, 300.0, 150.0); + assert!(h.state().ui.views[0].zoom > z0 * 1.5, "{z0} -> {}", h.state().ui.views[0].zoom); + assert_eq!(h.state().ui.tool, Tool::Hand); +} + +/// With Scrubby Zoom off, a ⌘-drag with the Hand draws the Zoom tool's rectangle instead. The +/// drag is nearly vertical, so a scrubby zoom (horizontal motion only) would barely change the +/// zoom, while zooming to the narrow rectangle fills the view with it. +#[test] +fn cmd_drag_with_the_hand_tool_follows_scrubby_zoom_off() { + let mut h = harness(Tool::Hand); + h.state_mut().ui.tool_options.zoom_scrubby = false; + let z0 = h.state().ui.views[0].zoom; + drag_with(&mut h, Modifiers::COMMAND, 150.0, 100.0, 160.0, 180.0); + assert!(h.state().ui.views[0].zoom > z0 * 2.0, "{z0} -> {}", h.state().ui.views[0].zoom); + assert_eq!(h.state().ui.tool, Tool::Hand); +} + +/// ⌘ or ⌥ pressed during a Hand pan doesn't turn the drag into a zoom: the pan goes on. +#[test] +fn modifiers_pressed_during_a_hand_pan_keep_panning() { + for m in [Modifiers::COMMAND, Modifiers::ALT] { + let mut h = harness(Tool::Hand); + let (z0, c0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + press_at(&mut h, 200.0, 150.0); + hold_mods(&mut h, m); + assert_eq!(held_tool(h.state(), &h.ctx), None, "{m:?}"); + let p = screen(&h, 200.0, 150.0) + vec2(60.0, 40.0); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + button(&mut h, p, false, m); + hold_mods(&mut h, Modifiers::NONE); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, z0, "{m:?}: no zoom"); + assert!(v.center[0] < c0[0] - 30.0 && v.center[1] < c0[1] - 20.0, "{m:?}: panned {c0:?} -> {:?}", v.center); + } +} + +/// A middle-button pan with the Hand selected keeps panning when ⌘ or ⌥ goes down during it. +#[test] +fn modifiers_pressed_during_a_middle_button_pan_keep_panning() { + for m in [Modifiers::COMMAND, Modifiers::ALT] { + let mut h = harness(Tool::Hand); + let (z0, c0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + let p = screen(&h, 200.0, 150.0); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + h.event(Event::PointerButton { pos: p, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + hold_mods(&mut h, m); + assert_eq!(held_tool(h.state(), &h.ctx), None, "{m:?}"); + let q = p + vec2(60.0, 40.0); + h.event(Event::PointerMoved(q)); + h.run_steps(2); + h.event(Event::PointerButton { pos: q, button: PointerButton::Middle, pressed: false, modifiers: platform(m) }); + h.run_steps(1); + hold_mods(&mut h, Modifiers::NONE); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, z0, "{m:?}: no zoom"); + assert!(v.center[0] < c0[0] - 30.0 && v.center[1] < c0[1] - 20.0, "{m:?}: panned {c0:?} -> {:?}", v.center); + } +} + +/// Free Transform keeps ⌘ (its distort handles) while the Hand is selected. +#[test] +fn cmd_with_the_hand_tool_is_not_a_zoom_during_free_transform() { + let mut h = harness(Tool::Hand); + with_layer(&mut h); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.transform.is_some()); + hold_mods(&mut h, Modifiers::COMMAND); + assert_eq!(held_tool(h.state(), &h.ctx), None); +} + +/// The Modifier Keys panel's latched ⌥ and ⌘ count, for desktops that take Alt+click. +#[test] +fn latched_modifier_keys_zoom_with_the_hand_tool() { + let mut h = harness(Tool::Hand); + h.state_mut().ui.shell.sticky_command = true; + h.run_steps(1); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomIn)); + h.state_mut().ui.shell.sticky_alt = true; + h.run_steps(1); + assert_eq!(held_tool(h.state(), &h.ctx), Some(Temporary::ZoomOut)); + h.state_mut().ui.shell = Default::default(); + h.state_mut().ui.tool = Tool::Brush; + h.state_mut().ui.shell.sticky_alt = true; + h.run_steps(1); + assert_eq!(held_tool(h.state(), &h.ctx), None, "only the Hand's ⌥ zooms"); +} + +/// A Hand press made without a modifier stays the Hand through its release: ⌘ or ⌥ pressed +/// before letting go (without moving past the drag threshold) doesn't zoom on the release. +#[test] +fn modifiers_pressed_before_a_hand_click_is_released_do_not_zoom() { + for m in [Modifiers::COMMAND, Modifiers::ALT] { + let mut h = harness(Tool::Hand); + let (z0, c0) = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + let p = screen(&h, 200.0, 150.0); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + button(&mut h, p, true, Modifiers::NONE); + hold_mods(&mut h, m); + button(&mut h, p, false, m); + h.run_steps(1); + let v = &h.state().ui.views[0]; + assert_eq!((v.zoom, v.center), (z0, c0), "{m:?}: the release didn't zoom"); + // The modifier still zooms the next click. + click(&mut h, 200.0, 150.0, m); + assert_ne!(h.state().ui.views[0].zoom, z0, "{m:?}"); + hold_mods(&mut h, Modifiers::NONE); + } +} + +/// A click whose press and release arrive in one frame, with ⌘ held, still zooms. +#[test] +fn a_one_frame_cmd_click_with_the_hand_tool_zooms() { + let mut h = harness(Tool::Hand); + let z0 = h.state().ui.views[0].zoom; + hold_mods(&mut h, Modifiers::COMMAND); + let p = screen(&h, 200.0, 150.0); + h.event(Event::PointerMoved(p)); + h.run_steps(1); + let m = platform(Modifiers::COMMAND); + h.input_mut().events.extend([ + Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: m }, + Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: m }, + ]); + h.run_steps(2); + assert!(h.state().ui.views[0].zoom > z0); +} diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 93a609f946..0e9c354446 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ PhotoCraft's Korean, German, Spanish, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, -Turkish, Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, -at your option. +Turkish, Hungarian, Serbian and Russian UI translation catalogs are licensed under MIT OR +Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of @@ -37,3 +37,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Serbian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Russian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 15879f60d2..3cbbfe672b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -19,6 +19,10 @@ # (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin letters; the trailing "…" of # dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Možnosti masky… + Layer Mask Display Options… Možnosti zobrazení masky vrstvy… + # Channel Options and New Spot Channel (#3761) Channel Color Barva kanálu Color Indicates: Barva označuje: @@ -334,6 +338,7 @@ Blending Options… Možnosti prolnutí… Bevel & Emboss… Úkos a reliéf… Use Global Light Použít globální světlo + Overprint Přetisk Inner Shadow… Vnitřní stín… Inner Glow… Vnitřní záře… Satin… Satén… @@ -1104,7 +1109,7 @@ Done Hotovo Dots Tečky Drag a rectangle around the object Táhněte obdélník kolem objektu - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím odstraníte. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Tažením v obraze přidáte omezení; Shift pro vodorovné/svislé; pravým kliknutím na omezení ho změníte nebo odstraníte. Drag to move the active shape's path or the Work Path Tažením přesunete cestu aktivního tvaru nebo pracovní cestu Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: vybrat bod · Tažení: přesunout bod, úchyt nebo segment · {shift}+klik: přidat · {alt}+klik: celá podcesta Draw with the Pen tool (P) or make a work path from a selection. Kreslete nástrojem pero (P) nebo vytvořte pracovní cestu z výběru. @@ -1346,6 +1351,8 @@ Override Pressure Přepsat přítlak Override Rotation Přepsat natočení Override Tilt Přepsat náklon + Override Tilt X Přepsat náklon X + Override Tilt Y Přepsat náklon Y PRESET DETAILS PODROBNOSTI PŘEDNASTAVENÍ PSD PSD Paint Overlay Překryv malby @@ -1498,6 +1505,7 @@ Set shape fill type Nastavit typ výplně tvaru Set shape stroke type Nastavit typ tahu tvaru Set the text color Nastavit barvu textu + Create warped text Vytvořit deformovaný text Shadows Stíny Shape: Tvar: Sharpening Doostření @@ -2007,6 +2015,7 @@ Quick export format Formát rychlého exportu Quick export location Umístění rychlého exportu Recent file count Počet nedávných souborů + LUT folder Složka LUT Recent files Nedávné soubory Recent fonts Nedávná písma Recover on launch Obnovit při spuštění @@ -2252,6 +2261,8 @@ GPU acceleration could not continue. Your documents are unchanged. Akcelerace GPU nemohla pokračovat. Dokumenty zůstaly nezměněny. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft skládá obraz pomocí CPU. Okno může nadále používat grafický adaptér. Retry GPU requires a restart. Save your work first. Opakování GPU vyžaduje restart. Nejprve uložte práci. + Retry GPU tries the graphics processor again now. Opakování GPU zkusí grafický procesor znovu hned teď. + GPU acceleration resumed. Akcelerace GPU obnovena. Keep Using CPU Pokračovat s CPU Retry GPU Zkusit GPU znovu Advanced Pokročilé @@ -2311,6 +2322,11 @@ cameraRaw Color Barvy Content-Aware Move Tool Nástroj přesun s ohledem na obsah TIFF Options Možnosti TIFF Targa Options Možnosti Targa + JPEG Options Možnosti JPEG + Image Options Možnosti obrazu + Format Options Možnosti formátu + Baseline ("Standard") Základní („Standardní“) + Baseline Optimized Základní optimalizovaný 24 bits/pixel 24 bitů/pixel 32 bits/pixel 32 bitů/pixel Including layers will increase file size. Zahrnutí vrstev zvětší velikost souboru. @@ -2374,11 +2390,11 @@ cameraRaw Color Barvy Apply At Canvas Bounds Použít efekt na hranicích plátna # Shared screen eyedropper and text Color Picker (#1128). - Color Picker (Text Color) Výběr barvy (Barva textu) + Color Picker (Text Color) Výběr barvy (barva textu) Color Picker ({name}) Výběr barvy ({name}) - Color Picker (Fill Color) Výběr barvy (Barva výplně) + Color Picker (Fill Color) Výběr barvy (barva výplně) Fill color Barva výplně - Color Picker (Stroke Color) Výběr barvy (Barva tahu) + Color Picker (Stroke Color) Výběr barvy (barva tahu) Stroke color Barva tahu Pick screen color Vybrat barvu obrazovky Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce @@ -2530,7 +2546,7 @@ fontWeight Italic Kurzíva Match Canvas Podle plátna Auto Adjust Opacity Automaticky upravit krytí Crop Shield color Barva stínění oříznutí - Color Picker (Crop Shield Color) Výběr barvy (Barva stínění oříznutí) + Color Picker (Crop Shield Color) Výběr barvy (barva stínění oříznutí) From Center Ze středu Link radii Propojit poloměry Star Ratio: Poměr hvězdy: @@ -2647,7 +2663,7 @@ cameraData Lens Objektiv Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení # Edit › Content-Aware Fill workspace - Sampling Brush Tool Nástroj Štětec vzorkování + Sampling Brush Tool Nástroj štětec vzorkování Sampling Area Overlay Překrytí oblasti vzorkování Show Sampling Area Zobrazit oblast vzorkování Indicates Označuje @@ -2667,6 +2683,36 @@ cameraData Lens Objektiv Duplicate Layer Duplikovat vrstvu Contract Zúžit + Animation Options Možnosti animace + Entire Document Celý dokument + Filter Options Možnosti filtrování + Layer Bounds Hranice vrstvy + Layers Panel Options Možnosti panelu Vrstvy + Lock Layers Zamknout vrstvy + New Artboard… Nové kreslicí plátno… + New Group Nová skupina + New Group… Nová skupina… + New Layer… Nová vrstva… + Panel Options… Možnosti panelu… + Prevent Auto-Nesting Zabránit automatickému vnoření + Show Layers Zobrazit vrstvy + Thumbnail Contents Obsah miniatur + Thumbnail Size Velikost miniatur + Use Default Masks on Fill Layers Používat výchozí masky u vrstev výplně + Expand New Effects Rozbalovat nové efekty + Add “copy” to Copied Layers and Groups Přidávat „kopie“ ke kopírovaným vrstvám a skupinám + Preview on canvas when reordering layers Zobrazovat náhled na plátně při změně pořadí vrstev + Show layer mask badges Zobrazovat odznaky masek vrstev + +# Smart Filter editors + Blending Options ({filter}) Volby prolnutí ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Skrýt efekty vrstvy a inteligentní filtry (kliknutí s {key}: všechny vrstvy) + Show the layer's effects and Smart Filters ({key}-click: all layers) Zobrazit efekty vrstvy a inteligentní filtry (kliknutí s {key}: všechny vrstvy) + Wait for the current job to finish. Počkejte na dokončení aktuální úlohy. + The document or Smart Filter changed. Reopen its dialog. Dokument nebo inteligentní filtr se změnil. Znovu otevřete jeho dialog. + Opacity must be between 0% and 100%. Krytí musí být mezi 0% a 100%. + Dissolve is approximated: it blends without Photoshop's dither. Režim Rozpustit je pouze aproximován: prolíná bez ditheringu Photoshopu. + # Dynamic labels the tl! literal scanner cannot see: filter and Filter Gallery parameter names and # choices (humanized from their keys), Camera Raw controls, render filters (Flame, Picture Frame, Tree), # measurement columns, file info fields and the macOS application menu. Translated the same way as @@ -2815,6 +2861,9 @@ cameraData Lens Objektiv Cooling80 Ochlazovací 80 Cooling82 Ochlazovací 82 Copyright Status Stav autorských práv + Copyright Info URL URL informace o autorských právech + Copyright Notice Oznámení autorských práv + Document Title Název dokumentu Copyrighted Chráněno autorským právem Correct C A Korigovat chromatickou aberaci Correct Distortion Korigovat zkreslení @@ -2884,6 +2933,7 @@ cameraData Lens Objektiv Duplicate Layer Comp Duplikovat kompozici vrstev Duplicate Layers Duplikovat vrstvy Duplicate Path Duplikovat cestu + Rename Path… Přejmenovat cestu… Duplication Duplikace Duration Doba trvání Dust & Scratches Prach a škrábance @@ -3088,7 +3138,6 @@ cameraData Lens Objektiv Liquify Zkapalnit List Seznam Load Selection Načíst výběr - Lock Layers Zamknout vrstvy Locked Zamčeno Long Lines Dlouhé čáry Long Strokes Dlouhé tahy @@ -3136,11 +3185,9 @@ cameraData Lens Objektiv Neon Glow Neonová záře Neutralize Neutralizovat New Channel Nový kanál - New Group Nová skupina New Guide Nové vodítko New Guide Layout Nové rozvržení vodítek New Layer Comp Nová kompozice vrstev - New Layer… Nová vrstva… New Video Layer from File Nová video vrstva ze souboru New document Nový dokument New document… ⌘N Nový dokument… ⌘N @@ -3551,6 +3598,12 @@ curves Smooth Vyhladit Click to place · Alt-click or drag off-canvas to remove Kliknutím umístíte · Alt-kliknutím nebo přetažením mimo plátno odeberete No document open Není otevřen žádný dokument {n} of {max} sample points {n} z {max} bodů měření + +# History panel menu (#2966) + Step Forward Krok vpřed + Clear History Vymazat historii + New Snapshot… Nový snímek… + History Options… Volby historie… Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Táhněte (laso) nebo klikejte na body (mnohoúhelník) · {add} přidat · {sub} odečíst The current gradient (pick one in Window › Gradients) Aktuální přechod (vyberte v Okno › Přechody) # Canvas context menus, Channels panel menu, Layer Style blending page (labels rendered through a variable). @@ -3741,9 +3794,16 @@ navigator Zoom Zvětšení Medium Blue Středně modrá Object Finder Hledání objektů No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivní vrstva tu nemá žádné pixely — zapněte Vzorkovat všechny vrstvy nebo vyberte vrstvu s pixely Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Load Contours… Načíst obrysy… + Replace Contours… Nahradit obrysy… + Save Contours… Uložit obrysy… + Delete Contour Smazat obrys + Loaded contours from {file} Obrysy načteny ze souboru {file} + Saved contours to {file} Obrysy uloženy do souboru {file} Show home recent files Zobrazit nedávné soubory na úvodní obrazovce Clear Brush Controls Obnovit ovládací prvky stopy Reset All Locked Settings Obnovit všechna zamknutá nastavení @@ -3752,6 +3812,9 @@ navigator Zoom Zvětšení # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Načíst akce… Save Actions… Uložit akce… + New Action… Nová akce… + Start Recording Zahájit nahrávání + Stop Recording Zastavit nahrávání {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Načtena {n} akce ze souboru {file}|Načteny {n} akce ze souboru {file}|Načteno {n} akcí ze souboru {file} @@ -3783,11 +3846,21 @@ navigator Zoom Zvětšení Sent to {printer} Odesláno do {printer} set in Print Settings… nastavuje se v nastavení tisku… ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maluje ze zdrojového stavu panelu Historie + Sets the source for the history brush Nastaví zdroj pro štětec historie +# Bevel & Emboss shading controls. + Highlight Mode Režim světel + Highlight Color Barva světel + Highlight Opacity Krytí světel + Shadow Mode Režim stínů + Shadow Color Barva stínů + Shadow Opacity Krytí stínů # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Použít vložený profil ({profile}) Convert to the working space ({profile}) Převést do pracovního prostoru barev ({profile}) Remember this choice for future images Zapamatovat si tuto volbu pro další obrázky - Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou Ask On Mismatch v nabídce Úpravy › Nastavení barev. + Turn the prompt back on with Ask On Mismatch in Edit › Color Settings. Dotaz znovu zapnete volbou „Ptát se při nesouladu profilů“ v nabídce Úpravy › Nastavení barev. Button Mode Režim tlačítka Button Mode On Režim tlačítka zapnutý @@ -3799,10 +3872,14 @@ navigator Zoom Zvětšení Chisel Hard Tvrdé tesání Chisel Soft Měkké tesání Open color-less EXR as ACEScg Otevřít EXR bez údaje o barevném prostoru jako ACEScg + Do not append "copy" to filename when saving a copy Nepřidávat „copy“ k názvu souboru při ukládání kopie # View › Guides › right-click a guide Edit Selected Guides Upravit vybraná vodítka Clear Selected Guides Vymazat vybraná vodítka + Bicubic Automatic Bikubická automatická + Bicubic Smoother Bikubická hladší + Bicubic Sharper Bikubická ostřejší Pasting from other apps is unavailable Vkládání z jiných aplikací není k dispozici PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft v této relaci nemá přístup k systémové schránce. Na Waylandu sandbox Flatpaku skrývá protokol, přes který se čtou obrázky z jiných aplikací. To paste from other apps, start PhotoCraft under XWayland: `{command}` Chcete-li vkládat z jiných aplikací, spusťte PhotoCraft pod XWaylandem: `{command}` @@ -3822,6 +3899,17 @@ navigator Zoom Zvětšení set name název sady {set} (modified) {set} (změněno) Show file name in title Zobrazit název souboru v titulu + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50% práh + Pattern Dither Rozptyl se vzorkem + Diffusion Dither Difuzní rozptyl + Halftone Screen Polotónový rastr + Custom Pattern Vlastní vzorek + Frequency: Frekvence: + Lines/Inch Linky/palec + Lines/Centimeter Linky/centimetr + Ellipse Elipsa Accept and Go to Conflict Přijmout a přejít ke konfliktu Changes undone. Změny vráceny. Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní. @@ -3831,6 +3919,7 @@ navigator Zoom Zvětšení {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění) # Free Transform: a new layer while the box is open (#5859) + Reference point location Umístění referenčního bodu Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí. The transform couldn't be applied. Transformaci nelze použít. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -3839,15 +3928,165 @@ navigator Zoom Zvětšení GPU Memory Full Paměť GPU je plná The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Vrstvy potřebují {needed} paměti GPU a mohou zabrat {budget}, takže úpravy, které překreslují celý dokument, to dělají na CPU. Kliknutím nastavíte Paměť GPU. GPU memory budget: {budget} Rozpočet paměti GPU: {budget} + Graphics memory: {size} Paměť GPU: {size} This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) + {path}: the path is not valid Unicode; save under another name. {path}: cesta není platný Unicode; uložte soubor pod jiným názvem. {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. Decrease Brush Size Zmenšit velikost štětce Increase Brush Size Zvětšit velikost štětce Decrease Brush Hardness Snížit tvrdost štětce Increase Brush Hardness Zvýšit tvrdost štětce + Demosaic Demozaikování + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Jak se doplňuje mozaika barevného filtru fotoaparátu. Použije se při otevření souboru; náhled ukazuje metodu, se kterou byl otevřen. + AHD (adaptive homogeneity) AHD (adaptivní homogenita) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: nejčistší hrany a nejméně barevných lemů, nejpomalejší. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolace s korekcí gradientu: ostrá a rychlá, s mírnými barevnými lemy v jemných detailech. + The fastest and the softest, with colour fringing on fine detail. Nejrychlejší a nejměkčí, s barevnými lemy v jemných detailech. + “{name}” already exists. Replace it? „{name}“ již existuje. Nahradit? + The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“. + +# Labels the UI looks up through a variable (tool, glyph-group, Liquify and warp names, command +# parameters and choices humanized from their keys, history steps and contextual +# Camera Raw / layer labels) that other catalogs already covered. Translated the same way as +# above, from the plain meaning of the English label. + Export As: {name} Exportovat jako: {name} + Tool Nástroj + Arc Lower Oblouk dole + Arc Upper Oblouk nahoře + Arrows Šipky + Basic Latin Základní latinka + Bloat Nafouknout + Burn Ztmavení + Checkerboard Šachovnice + Clone Stamp Klonovací razítko + Color Sampler Tool Nástroj vzorkovač barev + Currency Měnové symboly + Cyrillic Cyrilice + Decrease Type Size Zmenšit velikost písma + Decrease Type Size by 10 Zmenšit velikost písma o 10 + Delete Snapshot Odstranit snímek + Detect Faces Rozpoznat obličeje + Dodge Zesvětlení + Erase with Brush or Pencil (hold) Mazat štětcem nebo tužkou (při podržení klávesy) + Fill with Background Color Vyplnit barvou pozadí + Fill with Background Color, Preserve Transparency Vyplnit barvou pozadí, zachovat průhlednost + Fill with Foreground Color Vyplnit barvou popředí + Fill with Foreground Color, Preserve Transparency Vyplnit barvou popředí, zachovat průhlednost + Forward Warp Deformovat tažením + Freeze Lasso Laso zmrazení + Freeze Mask Zmrazit oblast + Greek Řecké písmo + Hand Tool (hold) Nástroj ruka (při podržení klávesy) + History Brush Štětec historie + Increase Type Size Zvětšit velikost písma + Increase Type Size by 10 Zvětšit velikost písma o 10 + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: zvolte „Metrické“, „Optické“ nebo hodnotu v 1/1000 em (Alt+←/→ v místě textového kurzoru) + Latin 1 Latinka 1 + Latin Extended-A Rozšířená latinka A + Latin Extended-B Rozšířená latinka B + Lock Transparent Pixels Zamknout průhledné pixely + Make Selection from Path Vytvořit výběr z cesty + Mask None Zrušit maskování + Math Symbols Matematické symboly + New Snapshot Vytvořit snímek + Numbers Čísla + Pattern Fill… Výplň vzorkem… + Pick Color From Image Vybrat barvu z obrazu + Proof Setup… Nastavení nátisku… + Pucker Stáhnout + Push Left Tlačit doleva + Reconstruct All Rekonstruovat vše + Save Path Uložit cestu + Sponge Houba + Target Channel 3 Zacílit na kanál 3 + Target Channel 4 Zacílit na kanál 4 + Target Channel 5 Zacílit na kanál 5 + Target Channel 6 Zacílit na kanál 6 + Target Channel 7 Zacílit na kanál 7 + Target Channel 8 Zacílit na kanál 8 + Target Channel 9 Zacílit na kanál 9 + Target Composite Channel Zacílit na složený kanál + Temporary Dočasné + Thaw Mask Rozmrazit oblast + Toggle Layer Mask Overlay Přepnout překryv masky vrstvy + Twirl Clockwise Zvířit doprava + Twirl Counterclockwise Zvířit doleva + Warming Filter (85) Oteplovací filtr (85) + Zoom In (hold, drag to scrub) Přiblížit (při podržení klávesy, tažením plynule měníte zvětšení) + Zoom Out (hold) Oddálit (při podržení klávesy) + blue modrá + gray šedá + green zelená + none žádná + orange oranžová + red červená + violet fialová + yellow žlutá + Adobe-rgb-compat Kompatibilní s Adobe RGB (1998) + Any Bez omezení + Ask On Mismatch Ptát se při nesouladu profilů + Ask On Missing Ptát se při chybějícím profilu + Ask On Paste Ptát se při vkládání + Author Title Pracovní pozice autora + Based On Podle + Blend Text Gamma Gama prolnutí textu + Case Sensitive Rozlišovat velikost písmen + Ccw Proti směru hodinových ručiček + Change Find Změnit a najít další + Cmyk CMYK + Coated-cmyk CMYK pro křídový papír + Convert Převést + Copyright Url URL informací o autorských právech + Cw Po směru hodinových ručiček + Display-p3 Display P3 + Dont Enlarge Nezvětšovat + Forward Vpřed + Horizontal Distortion Vodorovné zkreslení + Intent Záměr převodu + Linear-srgb Lineární sRGB + Mask Channel Kanál masky + Monitor Profile Profil monitoru + Nearest Nejbližší soused + Policy Cmyk Zásady správy barev pro CMYK + Policy Gray Zásady správy barev pro šedou + Policy Rgb Zásady správy barev pro RGB + Prophoto-compat Kompatibilní s ProPhoto + R G B RGB + Rec2020 Rec. 2020 + Sgray sGray (tónová křivka sRGB) + Simulate Paper Simulovat papír + Source Channel Zdrojový kanál + Source Invert Invertovat zdroj + Source1 Channel Kanál zdroje 1 + Source1 Invert Invertovat zdroj 1 + Source2 Channel Kanál zdroje 2 + Source2 Invert Invertovat zdroj 2 + Title Název + Vertical Distortion Svislé zkreslení + Whole Word Celé slovo + Working Cmyk Pracovní CMYK + Working Gray Pracovní šedá + Working Rgb Pracovní RGB + Working-cmyk Pracovní CMYK + Warming Filter (LBA) Oteplovací filtr (LBA) + Warming Filter (81) Oteplovací filtr (81) + Cooling Filter (80) Ochlazovací filtr (80) + Cooling Filter (LBB) Ochlazovací filtr (LBB) + Cooling Filter (82) Ochlazovací filtr (82) +layers Fill Krytí výplně +layers Fill: Krytí výplně: +cameraRaw Dehaze Odstranění oparu + Move Up Přesunout nahoru + Move Down Přesunout dolů + Previous Brush Předchozí štětec + Next Brush Další štětec + First Brush První štětec + Last Brush Poslední štětec + Preview size Preview size Memory Usage Využití paměti Per-document RAM limit (MB) Limit RAM na dokument (MB) Installed RAM: {ram} MB; this limit: {percent}% Nainstalovaná RAM: {ram} MB; tento limit: {percent}% @@ -3862,5 +4101,28 @@ navigator Zoom Zvětšení DDS Options Možnosti DDS Generate Mipmaps Generovat mipmapy +# Color Table (.act) dialog + There is no color table to save. Neexistuje žádná tabulka barev k uložení. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Uložena {n} barva do {file}|Uloženy {n} barvy do {file}|Uloženo {n} barev do {file} + # Graphics Adapter preference (#1885) GPU adapter Adaptér GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Nejprve potvrďte nebo zrušte otevřený dialog kroku akce + Pro Medium Gray Pro (středně šedý) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (světle šedý) + Pro Light Pro (světlý) + Resize Windows to Fit Přizpůsobit velikost oken + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatický směr odstavce + Left-to-right paragraph direction Směr odstavce zleva doprava + Right-to-left paragraph direction Směr odstavce zprava doleva + Not rendered: this adjustment ({key}) is kept as imported. Nevykresleno: tato úprava ({key}) je ponechána tak, jak byla importována. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eb3ca77944..cecf610a2b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -15,6 +15,10 @@ # way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskenoptionen… + Layer Mask Display Options… Anzeigeoptionen für Ebenenmasken… + # Channel Options and New Spot Channel (#3761) Channel Color Kanalfarbe Color Indicates: Farbe kennzeichnet: @@ -333,6 +337,7 @@ Blending Options… Fusionsoptionen… Bevel & Emboss… Abgeflachte Kante & Relief… Use Global Light Globales Licht verwenden + Overprint Überdrucken Inner Shadow… Innerer Schatten… Inner Glow… Schein nach innen… Satin… Satin… @@ -1087,7 +1092,7 @@ Done Fertig Dots Punkte Drag a rectangle around the object Ziehe ein Rechteck um das Objekt - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Ziehe auf dem Bild, um eine Zwangsbedingung hinzuzufügen; Shift für horizontal/vertikal; Rechtsklick löscht. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Ziehe auf dem Bild, um eine Zwangsbedingung hinzuzufügen; Shift für horizontal/vertikal; Rechtsklick auf eine Zwangsbedingung ändert oder löscht sie. Drag to move the active shape's path or the Work Path Ziehen, um den Pfad der aktiven Form oder den Arbeitspfad zu verschieben Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klick: Ankerpunkt auswählen · Ziehen: Ankerpunkt, Griff oder Segment verschieben · {shift}-Klick: hinzufügen · {alt}-Klick: ganzer Teilpfad Draw with the Pen tool (P) or make a work path from a selection. Zeichne mit dem Stift-Werkzeug (P) oder erzeuge einen Arbeitspfad aus einer Auswahl. @@ -1329,6 +1334,8 @@ Override Pressure Druck überschreiben Override Rotation Rotation überschreiben Override Tilt Neigung überschreiben + Override Tilt X Neigung X überschreiben + Override Tilt Y Neigung Y überschreiben PRESET DETAILS VORGABENDETAILS PSD PSD Paint Overlay Übermalung @@ -1481,6 +1488,7 @@ Set shape fill type Fülltyp der Form festlegen Set shape stroke type Konturtyp der Form festlegen Set the text color Textfarbe festlegen + Create warped text Verkrümmten Text erstellen Shadows Tiefen Shape: Form: Sharpening Scharfzeichnung @@ -1981,6 +1989,7 @@ Quick export format Schnellexport-Format Quick export location Schnellexport-Ziel Recent file count Anzahl zuletzt geöffneter Dateien + LUT folder LUT-Ordner Recent files Zuletzt geöffnete Dateien Recent fonts Zuletzt verwendete Schriften Recover on launch Beim Start wiederherstellen @@ -2250,6 +2259,7 @@ Fill Path Pfad füllen Stroke Path Pfad nachziehen Delete Path Pfad löschen + Rename Path… Pfad umbenennen… W B H H Ruler Lineal @@ -2301,6 +2311,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU-Beschleunigung konnte nicht fortgesetzt werden. Deine Dokumente sind unverändert. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft nutzt den CPU-Bildkompositor. Das Fenster kann weiterhin deinen Grafikadapter verwenden. Retry GPU requires a restart. Save your work first. GPU erneut versuchen erfordert einen Neustart. Speichere zuerst deine Arbeit. + Retry GPU tries the graphics processor again now. GPU erneut versuchen versucht es sofort wieder mit dem Grafikprozessor. + GPU acceleration resumed. GPU-Beschleunigung wieder aktiv. Keep Using CPU CPU weiter verwenden Retry GPU GPU erneut versuchen Advanced Erweitert @@ -2347,6 +2359,11 @@ Content-Aware Move Tool Inhaltsbasiert-verschieben-Werkzeug TIFF Options TIFF-Optionen Targa Options Targa-Optionen + JPEG Options JPEG-Optionen + Image Options Bildoptionen + Format Options Formatoptionen + Baseline ("Standard") Baseline („Standard“) + Baseline Optimized Baseline optimiert 24 bits/pixel 24 Bit/Pixel 32 bits/pixel 32 Bit/Pixel Including layers will increase file size. Das Einbeziehen von Ebenen vergrößert die Datei. @@ -2698,6 +2715,35 @@ cameraData Lens Objektiv Output To Ausgabe in Duplicate Layer Ebene duplizieren Contract Verkleinern + + Animation Options Animationsoptionen + Entire Document Gesamtes Dokument + Filter Options Filteroptionen + Layer Bounds Ebenenbegrenzungen + Layers Panel Options Optionen des Ebenenbedienfelds + Lock Layers Ebenen sperren + New Artboard… Neue Zeichenfläche… + New Group… Neue Gruppe… + New Layer… Neue Ebene… + Panel Options… Bedienfeldoptionen… + Prevent Auto-Nesting Automatisches Verschachteln verhindern + Show Layers Ebenen einblenden + Thumbnail Contents Miniaturinhalt + Thumbnail Size Miniaturgröße + Use Default Masks on Fill Layers Standardmasken für Füllebenen verwenden + Expand New Effects Neue Effekte aufklappen + Add “copy” to Copied Layers and Groups „Kopie“ zu kopierten Ebenen und Gruppen hinzufügen + Preview on canvas when reordering layers Vorschau auf der Arbeitsfläche beim Neuanordnen von Ebenen + Show layer mask badges Ebenenmasken-Abzeichen anzeigen + +# Smart Filter editors + Blending Options ({filter}) Fülloptionen ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ebeneneffekte und Smartfilter ausblenden ({key}-Klick: alle Ebenen) + Show the layer's effects and Smart Filters ({key}-click: all layers) Ebeneneffekte und Smartfilter einblenden ({key}-Klick: alle Ebenen) + Wait for the current job to finish. Warten Sie, bis der aktuelle Auftrag abgeschlossen ist. + The document or Smart Filter changed. Reopen its dialog. Das Dokument oder der Smartfilter wurde geändert. Öffnen Sie den Dialog erneut. + Opacity must be between 0% and 100%. Die Deckkraft muss zwischen 0% und 100% liegen. + Dissolve is approximated: it blends without Photoshop's dither. Sprenkeln wird nur angenähert: Es wird ohne das Dithering von Photoshop gemischt. Loading fonts… Schriften werden geladen… Response curve Reaktionskurve Tool Werkzeug @@ -2745,6 +2791,13 @@ curves Smooth Glätten Click to place · Alt-click or drag off-canvas to remove Klicken zum Platzieren · Alt-Klick oder Ziehen außerhalb der Arbeitsfläche zum Entfernen No document open Kein Dokument geöffnet {n} of {max} sample points {n} von {max} Messpunkten + +# History panel menu (#2966) + Step Forward Schritt vorwärts + Step Backward Schritt zurück + Clear History Protokoll löschen + New Snapshot… Neuer Schnappschuss… + History Options… Protokolloptionen… # Print dialog (#2697) Emulsion Down Schichtseite unten Functions Funktionen @@ -2826,6 +2879,11 @@ curves Smooth Glätten Increase Brush Size Pinselgröße erhöhen Decrease Brush Hardness Pinselhärte verringern Increase Brush Hardness Pinselhärte erhöhen + Previous Brush Vorheriger Pinsel + Next Brush Nächster Pinsel + First Brush Erster Pinsel + Last Brush Letzter Pinsel + Preview size Preview size Temporary Vorübergehend Space Leertaste Hand Tool (hold) Hand-Werkzeug (gedrückt halten) @@ -2835,6 +2893,9 @@ curves Smooth Glätten # Document Info and color profiles Author Title Position des Urhebers Copyright Status Urheberrechtsstatus + Copyright Info URL URL für Urheberrechtsinfo + Copyright Notice Urheberrechtsvermerk + Document Title Dokumenttitel Unknown Unbekannt Copyrighted Urheberrechtlich geschützt Public Domain Gemeinfrei @@ -3134,9 +3195,16 @@ curves Smooth Glätten Fuzziness Toleranz Object Finder Objektsucher No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Die aktive Ebene hat hier keine Pixel — aktivieren Sie „Alle Ebenen einbeziehen“ oder wählen Sie eine Ebene mit Pixeln Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Load Contours… Konturen laden… + Replace Contours… Konturen ersetzen… + Save Contours… Konturen speichern… + Delete Contour Kontur löschen + Loaded contours from {file} Konturen aus {file} geladen + Saved contours to {file} Konturen in {file} gespeichert Show home recent files Zuletzt geöffnete Dateien auf dem Startbildschirm anzeigen Clear Brush Controls Pinselsteuerungen löschen Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen @@ -3145,6 +3213,9 @@ curves Smooth Glätten # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Aktionen laden… Save Actions… Aktionen speichern… + New Action… Neue Aktion… + Start Recording Aufnahme starten + Stop Recording Aufnahme stoppen {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} Aktion aus {file} geladen|{n} Aktionen aus {file} geladen @@ -3177,6 +3248,16 @@ curves Smooth Glätten Sent to {printer} An {printer} gesendet set in Print Settings… in den Druckeinstellungen festlegen… ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt +# History panel › History Brush source (#2966) + Paints from the History panel's source state Malt aus dem Quellzustand des Protokollbedienfelds + Sets the source for the history brush Legt die Quelle für den Protokollpinsel fest +# Bevel & Emboss shading controls. + Highlight Mode Lichtermodus + Highlight Color Lichterfarbe + Highlight Opacity Lichterdeckkraft + Shadow Mode Schattenmodus + Shadow Color Schattenfarbe + Shadow Opacity Schattendeckkraft # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Eingebettetes Profil verwenden ({profile}) Convert to the working space ({profile}) In den Arbeitsfarbraum konvertieren ({profile}) @@ -3193,6 +3274,7 @@ curves Smooth Glätten Chisel Hard Meißeln hart Chisel Soft Meißeln weich Open color-less EXR as ACEScg Farbangabenloses EXR als ACEScg öffnen + Do not append "copy" to filename when saving a copy Beim Speichern einer Kopie „copy“ nicht an den Dateinamen anhängen # Export As metadata and profile options Color Profile Farbprofil EXIF EXIF @@ -3200,6 +3282,9 @@ curves Smooth Glätten # View › Guides › right-click a guide Edit Selected Guides Ausgewählte Hilfslinien bearbeiten Clear Selected Guides Ausgewählte Hilfslinien löschen + Bicubic Automatic Bikubisch automatisch + Bicubic Smoother Bikubisch glatter + Bicubic Sharper Bikubisch schärfer Pasting from other apps is unavailable Einfügen aus anderen Apps ist nicht verfügbar PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft kann in dieser Sitzung nicht auf die Systemzwischenablage zugreifen. Unter Wayland verbirgt eine Flatpak-Sandbox das Protokoll, über das Bilder aus anderen Apps gelesen werden. To paste from other apps, start PhotoCraft under XWayland: `{command}` Um aus anderen Apps einzufügen, starte PhotoCraft unter XWayland: `{command}` @@ -3219,6 +3304,17 @@ curves Smooth Glätten set name Name des Satzes {set} (modified) {set} (geändert) Show file name in title Dateiname im Titel anzeigen + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50 %-Schwellenwert + Pattern Dither Musterdither + Diffusion Dither Diffusionsdither + Halftone Screen Rasterung + Custom Pattern Eigenes Muster + Frequency: Frequenz: + Lines/Inch Linien/Zoll + Lines/Centimeter Linien/Zentimeter + Ellipse Ellipse Accept and Go to Conflict Übernehmen und zum Konflikt Changes undone. Änderungen rückgängig gemacht. Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne. @@ -3228,6 +3324,7 @@ curves Smooth Glätten {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start) # Free Transform: a new layer while the box is open (#5859) + Reference point location Position des Referenzpunkts Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist. The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -3236,11 +3333,24 @@ curves Smooth Glätten GPU Memory Full GPU-Speicher voll The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Die Ebenen brauchen {needed} GPU-Speicher und dürfen {budget} belegen, daher zeichnen Änderungen, die das ganze Dokument neu zeichnen, auf der CPU. Klicken, um den GPU-Speicher einzustellen. GPU memory budget: {budget} GPU-Speicherbudget: {budget} + Graphics memory: {size} Grafikspeicher: {size} This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) + {path}: the path is not valid Unicode; save under another name. {path}: Der Dateipfad enthält ungültige Zeichen. Speichere unter einem anderen Namen. {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + Demosaic Demosaicing + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Wie das Farbfiltermosaik der Kamera aufgefüllt wird. Es wird beim Öffnen der Datei angewendet; die Vorschau zeigt die Methode, mit der sie geöffnet wurde. + AHD (adaptive homogeneity) AHD (adaptive Homogenität) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: die saubersten Kanten und die wenigsten Farbsäume, am langsamsten. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradientenkorrigierte Interpolation: scharf und schnell, mit etwas Farbsäumen an feinen Details. + The fastest and the softest, with colour fringing on fine detail. Am schnellsten und weichsten, mit Farbsäumen an feinen Details. + “{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen? + The name in the save dialog was “{name}”. Der Name im Speichern-Dialog war „{name}“. + Move Up Nach oben + Move Down Nach unten Memory Usage Speichernutzung Per-document RAM limit (MB) RAM-Limit pro Dokument (MB) Installed RAM: {ram} MB; this limit: {percent}% Installierter RAM: {ram} MB; dieses Limit: {percent}% @@ -3257,5 +3367,28 @@ curves Smooth Glätten Compression Komprimierung Generate Mipmaps Mipmaps erzeugen +# Color Table (.act) dialog + There is no color table to save. Es gibt keine Farbtabelle zum Speichern. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} Farbe in {file} gespeichert|{n} Farben in {file} gespeichert + # Graphics Adapter preference (#1885) GPU adapter GPU-Adapter +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bestätige oder brich zuerst den offenen Dialog des Aktionsschritts ab + Pro Medium Gray Pro (Mittelgrau) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (Hellgrau) + Pro Light Pro (Hell) + Resize Windows to Fit Fenstergröße anpassen + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatische Absatzrichtung + Left-to-right paragraph direction Absatzrichtung von links nach rechts + Right-to-left paragraph direction Absatzrichtung von rechts nach links + Not rendered: this adjustment ({key}) is kept as imported. Nicht dargestellt: Diese Einstellung ({key}) wird wie importiert beibehalten. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bac53b53d6..40069a334f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3,6 +3,10 @@ # Original translations based on general Greek graphics/computing terminology. # Covers every source label the i18n coverage check lists. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Επιλογές μάσκας… + Layer Mask Display Options… Επιλογές εμφάνισης μάσκας επιπέδου… + # Channel Options and New Spot Channel (#3761) Channel Color Χρώμα καναλιού Color Indicates: Το χρώμα υποδεικνύει: @@ -798,6 +802,8 @@ Override Pressure Παράκαμψη πίεσης Override Rotation Παράκαμψη περιστροφής Override Tilt Παράκαμψη κλίσης + Override Tilt X Παράκαμψη κλίσης X + Override Tilt Y Παράκαμψη κλίσης Y PRESET DETAILS ΛΕΠΤΟΜΕΡΕΙΕΣ ΠΡΟΕΠΙΛΟΓΗΣ Pattern Size Μέγεθος μοτίβου Perspective Warp is not active Ο μετασχηματισμός προοπτικής δεν είναι ενεργός @@ -995,6 +1001,8 @@ Keywords Λέξεις-κλειδιά Copyright Info URL Σύνδεσμος πληροφοριών πνευματικών δικαιωμάτων Copyright Status Κατάσταση πνευματικών δικαιωμάτων + Copyright Notice Σημείωση πνευματικών δικαιωμάτων + Document Title Τίτλος εγγράφου Copyrighted Προστατεύεται από πνευματικά δικαιώματα Public Domain Κοινό κτήμα Unknown Άγνωστο @@ -1106,6 +1114,8 @@ GPU acceleration could not continue. Your documents are unchanged. Η επιτάχυνση GPU δεν μπόρεσε να συνεχιστεί. Τα έγγραφά σας δεν έχουν αλλάξει. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. Το PhotoCraft χρησιμοποιεί σύνθεση εικόνας μέσω CPU. Το παράθυρο μπορεί να εξακολουθεί να χρησιμοποιεί την κάρτα γραφικών. Retry GPU requires a restart. Save your work first. Η νέα προσπάθεια χρήσης GPU απαιτεί επανεκκίνηση. Αποθηκεύστε πρώτα την εργασία σας. + Retry GPU tries the graphics processor again now. Η νέα προσπάθεια χρήσης GPU δοκιμάζει ξανά τον επεξεργαστή γραφικών αμέσως. + GPU acceleration resumed. Η επιτάχυνση GPU συνεχίζεται. Keep Using CPU Συνέχεια με CPU Retry GPU Νέα προσπάθεια χρήσης GPU GPU Backend Σύστημα GPU @@ -1117,6 +1127,11 @@ Hold Alt while drawing for straight segments Κρατήστε πατημένο το Alt κατά τη σχεδίαση για ευθύγραμμα τμήματα TIFF Options Επιλογές TIFF Targa Options Επιλογές Targa + JPEG Options Επιλογές JPEG + Image Options Επιλογές εικόνας + Format Options Επιλογές μορφής + Baseline ("Standard") Βασική («Τυπική») + Baseline Optimized Βασική βελτιστοποιημένη 24 bits/pixel 24 bit/εικονοστοιχείο 32 bits/pixel 32 bit/εικονοστοιχείο Including layers will increase file size. Η συμπερίληψη επιπέδων θα αυξήσει το μέγεθος του αρχείου. @@ -1819,6 +1834,7 @@ Blending Options… Επιλογές ανάμειξης… Bevel & Emboss… Λοξότμηση και ανάγλυφο… Use Global Light Χρήση καθολικού φωτισμού + Overprint Υπερεκτύπωση Inner Shadow… Εσωτερική σκιά… Inner Glow… Εσωτερική λάμψη… Satin… Σατινέ… @@ -2346,13 +2362,14 @@ Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Σύρετε για λάσο ή κάντε κλικ για πολυγωνική επιλογή · {add} προσθήκη · {sub} αφαίρεση Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Σύρετε για περιοχή περικοπής · σύρετε στο εσωτερικό για μετακίνηση · από τα άκρα αλλάζει μέγεθος ({ratio} αναλογία, {centre} κέντρο) · Space για μετακίνηση κατά τη σχεδίαση · βέλη για μετακίνηση · X εναλλαγή προσανατολισμού · {commit} εφαρμογή · Esc ακύρωση Drag around the centre to rotate the view · Shift constrains to 15° Σύρετε γύρω από το κέντρο για να περιστρέψετε την προβολή · Shift περιορίζει στις 15° - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Σύρετε πάνω στην εικόνα για προσθήκη περιορισμού· Shift για οριζόντιο/κατακόρυφο· δεξί κλικ για διαγραφή. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Σύρετε πάνω στην εικόνα για προσθήκη περιορισμού· Shift για οριζόντιο/κατακόρυφο· δεξί κλικ σε έναν περιορισμό για αλλαγή ή διαγραφή. Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Σύρετε ή κάντε κλικ για πολυγωνικά σημεία · κρατήστε Alt κατά το λάσο για ευθείες · {add} προσθήκη · {sub} αφαίρεση πριν από τη σχεδίαση Drag to draw a gradient Σύρετε για να σχεδιάσετε διαβάθμιση Drag to move the active layer Σύρετε για μετακίνηση του ενεργού επιπέδου Drag to move the active shape's path or the Work Path Σύρετε για μετακίνηση της διαδρομής του ενεργού σχήματος ή της διαδρομής εργασίας Dual Brush Διπλό πινέλο Duplicate Path Διπλασιασμός διαδρομής + Rename Path… Μετονομασία διαδρομής… Duplicate style Διπλασιασμός στυλ Dust & Scratches Σκόνη και γρατζουνιές Dust & Scratches… Σκόνη και γρατζουνιές… @@ -2897,6 +2914,7 @@ Raw Data Ακατέργαστα δεδομένα Raw Defaults Προεπιλογές RAW Recent file count Αριθμός πρόσφατων αρχείων + LUT folder Φάκελος LUT Recent files Πρόσφατα αρχεία Recent fonts Πρόσφατες γραμματοσειρές Reconstruct Ανακατασκευή @@ -3038,6 +3056,7 @@ Set shape fill type Ορισμός τύπου γεμίσματος σχήματος Set shape stroke type Ορισμός τύπου περιγράμματος σχήματος Set the text color Ορισμός χρώματος κειμένου + Create warped text Δημιουργία παραμορφωμένου κειμένου Shadows/Highlights Σκιές/φωτεινές περιοχές Shape Σχήμα Shape Blur Θόλωση σχήματος @@ -3752,6 +3771,32 @@ cameraData Lens Φακός Mirror Κατοπτρισμός Output Settings Ρυθμίσεις εξόδου Output To Έξοδος σε + + Animation Options Επιλογές κίνησης + Entire Document Ολόκληρο έγγραφο + Filter Options Επιλογές φίλτρου + Layer Bounds Όρια επιπέδου + Layers Panel Options Επιλογές πίνακα επιπέδων + New Artboard… Νέος πίνακας σχεδίασης… + New Group… Νέα ομάδα… + Prevent Auto-Nesting Αποτροπή αυτόματης ένθεσης + Show Layers Εμφάνιση επιπέδων + Thumbnail Contents Περιεχόμενο μικρογραφιών + Thumbnail Size Μέγεθος μικρογραφιών + Use Default Masks on Fill Layers Χρήση προεπιλεγμένων μασκών σε επίπεδα γεμίσματος + Expand New Effects Ανάπτυξη νέων εφέ + Add “copy” to Copied Layers and Groups Προσθήκη «αντίγραφο» σε αντιγραμμένα επίπεδα και ομάδες + Preview on canvas when reordering layers Προεπισκόπηση στον καμβά κατά την αναδιάταξη επιπέδων + Show layer mask badges Εμφάνιση σημάτων μασκών επιπέδου + +# Smart Filter editors + Blending Options ({filter}) Επιλογές ανάμειξης ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Απόκρυψη εφέ επιπέδου και έξυπνων φίλτρων ({key}-κλικ: όλα τα επίπεδα) + Show the layer's effects and Smart Filters ({key}-click: all layers) Εμφάνιση εφέ επιπέδου και έξυπνων φίλτρων ({key}-κλικ: όλα τα επίπεδα) + Wait for the current job to finish. Περιμένετε να ολοκληρωθεί η τρέχουσα εργασία. + The document or Smart Filter changed. Reopen its dialog. Το έγγραφο ή το έξυπνο φίλτρο άλλαξε. Ανοίξτε ξανά το παράθυρό του. + Opacity must be between 0% and 100%. Η αδιαφάνεια πρέπει να είναι μεταξύ 0% και 100%. + Dissolve is approximated: it blends without Photoshop's dither. Η Διάλυση προσεγγίζεται: η ανάμειξη γίνεται χωρίς το dither του Photoshop. Loading fonts… Φόρτωση γραμματοσειρών… Response curve Καμπύλη απόκρισης Simulate Pressure Προσομοίωση πίεσης @@ -3797,6 +3842,10 @@ curves Smooth Εξομάλυνση # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Κλικ για τοποθέτηση · Alt-κλικ ή σύρσιμο εκτός καμβά για αφαίρεση {n} of {max} sample points {n} από {max} σημεία δειγματοληψίας + +# History panel menu (#2966) + New Snapshot… Νέο στιγμιότυπο… + History Options… Επιλογές ιστορικού… # Print dialog (#2697) Emulsion Down Γαλάκτωμα προς τα κάτω Functions Λειτουργίες @@ -3831,9 +3880,16 @@ curves Smooth Εξομάλυνση Light Gray Ανοιχτό γκρι Object Finder Εύρεση αντικειμένων No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Το ενεργό επίπεδο δεν έχει εικονοστοιχεία εδώ — ενεργοποιήστε τη Δειγματοληψία όλων των επιπέδων ή επιλέξτε ένα επίπεδο με εικονοστοιχεία Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Load Contours… Φόρτωση περιγραμμάτων… + Replace Contours… Αντικατάσταση περιγραμμάτων… + Save Contours… Αποθήκευση περιγραμμάτων… + Delete Contour Διαγραφή περιγράμματος + Loaded contours from {file} Φορτώθηκαν περιγράμματα από {file} + Saved contours to {file} Τα περιγράμματα αποθηκεύτηκαν στο {file} Show home recent files Εμφάνιση πρόσφατων αρχείων στην αρχική οθόνη Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων @@ -3842,6 +3898,9 @@ curves Smooth Εξομάλυνση # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Φόρτωση ενεργειών… Save Actions… Αποθήκευση ενεργειών… + New Action… Νέα ενέργεια… + Start Recording Έναρξη εγγραφής + Stop Recording Διακοπή εγγραφής {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Φορτώθηκε {n} ενέργεια από το {file}|Φορτώθηκαν {n} ενέργειες από το {file} @@ -3873,6 +3932,16 @@ curves Smooth Εξομάλυνση Sent to {printer} Στάλθηκε στον {printer} set in Print Settings… ορίζεται στις ρυθμίσεις εκτύπωσης… ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία +# History panel › History Brush source (#2966) + Paints from the History panel's source state Βάφει από την κατάσταση πηγής του πίνακα Ιστορικό + Sets the source for the history brush Ορίζει την πηγή για το πινέλο ιστορικού +# Bevel & Emboss shading controls. + Highlight Mode Λειτουργία φωτεινών τόνων + Highlight Color Χρώμα φωτεινών τόνων + Highlight Opacity Αδιαφάνεια φωτεινών τόνων + Shadow Mode Λειτουργία σκιών + Shadow Color Χρώμα σκιών + Shadow Opacity Αδιαφάνεια σκιών # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Χρήση ενσωματωμένου προφίλ ({profile}) Convert to the working space ({profile}) Μετατροπή στον χώρο εργασίας ({profile}) @@ -3889,12 +3958,16 @@ curves Smooth Εξομάλυνση Chisel Hard Σκληρή σμίλευση Chisel Soft Μαλακή σμίλευση Open color-less EXR as ACEScg Άνοιγμα EXR χωρίς χρωματική δήλωση ως ACEScg + Do not append "copy" to filename when saving a copy Να μην προστίθεται «copy» στο όνομα αρχείου κατά την αποθήκευση αντιγράφου # Export As metadata and profile options EXIF EXIF # View › Guides › right-click a guide Edit Selected Guides Επεξεργασία επιλεγμένων οδηγών Clear Selected Guides Απαλοιφή επιλεγμένων οδηγών + Bicubic Automatic Δικυβική αυτόματη + Bicubic Smoother Δικυβική πιο ομαλή + Bicubic Sharper Δικυβική πιο ευκρινής Pasting from other apps is unavailable Η επικόλληση από άλλες εφαρμογές δεν είναι διαθέσιμη PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. Το PhotoCraft δεν έχει πρόσβαση στο πρόχειρο του συστήματος σε αυτή τη συνεδρία. Στο Wayland, το sandbox του Flatpak κρύβει το πρωτόκολλο μέσω του οποίου διαβάζονται εικόνες από άλλες εφαρμογές. To paste from other apps, start PhotoCraft under XWayland: `{command}` Για επικόλληση από άλλες εφαρμογές, ξεκινήστε το PhotoCraft σε XWayland: `{command}` @@ -3914,6 +3987,17 @@ curves Smooth Εξομάλυνση set name όνομα συνόλου {set} (modified) {set} (τροποποιημένο) Show file name in title Εμφάνιση ονόματος αρχείου στον τίτλο + +# Image › Mode › Bitmap (#5559) + 50% Threshold Κατώφλι 50% + Pattern Dither Πρόσμιξη μοτίβου + Diffusion Dither Πρόσμιξη διάχυσης + Halftone Screen Ράστερ ημιτόνων + Custom Pattern Προσαρμοσμένο μοτίβο + Frequency: Συχνότητα: + Lines/Inch Γραμμές/ίντσα + Lines/Centimeter Γραμμές/εκατοστό + Ellipse Έλλειψη Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση Changes undone. Οι αλλαγές αναιρέθηκαν. Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς. @@ -3931,15 +4015,33 @@ curves Smooth Εξομάλυνση GPU Memory Full Η μνήμη GPU είναι πλήρης The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Τα επίπεδα χρειάζονται {needed} μνήμης GPU και μπορούν να καταλάβουν {budget}, οπότε οι αλλαγές που ξανασχεδιάζουν όλο το έγγραφο το κάνουν στη CPU. Κάντε κλικ για να ορίσετε τη Μνήμη GPU. GPU memory budget: {budget} Όριο μνήμης GPU: {budget} + Graphics memory: {size} Μνήμη γραφικών: {size} This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) + {path}: the path is not valid Unicode; save under another name. {path}: η διαδρομή δεν είναι έγκυρο Unicode· αποθηκεύστε με άλλο όνομα. {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. Decrease Brush Size Μείωση μεγέθους πινέλου Increase Brush Size Αύξηση μεγέθους πινέλου Decrease Brush Hardness Μείωση σκληρότητας πινέλου Increase Brush Hardness Αύξηση σκληρότητας πινέλου + Demosaic Αποψηφιδοποίηση + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Πώς συμπληρώνεται το ψηφιδωτό του χρωματικού φίλτρου της κάμερας. Εφαρμόζεται όταν ανοίγει το αρχείο· η προεπισκόπηση δείχνει τη μέθοδο με την οποία άνοιξε. + AHD (adaptive homogeneity) AHD (προσαρμοστική ομοιογένεια) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: οι πιο καθαρές ακμές και τα λιγότερα χρωματικά περιθώρια, η πιο αργή. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Παρεμβολή με διόρθωση κλίσης: ευκρινής και γρήγορη, με λίγα χρωματικά περιθώρια στις λεπτές λεπτομέρειες. + The fastest and the softest, with colour fringing on fine detail. Η πιο γρήγορη και η πιο μαλακή, με χρωματικά περιθώρια στις λεπτές λεπτομέρειες. + “{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί; + The name in the save dialog was “{name}”. Το όνομα στο παράθυρο αποθήκευσης ήταν «{name}». + Move Up Μετακίνηση επάνω + Move Down Μετακίνηση κάτω + Previous Brush Προηγούμενο πινέλο + Next Brush Επόμενο πινέλο + First Brush Πρώτο πινέλο + Last Brush Τελευταίο πινέλο + Preview size Preview size Memory Usage Χρήση μνήμης Per-document RAM limit (MB) Όριο RAM ανά έγγραφο (MB) Installed RAM: {ram} MB; this limit: {percent}% Εγκατεστημένη RAM: {ram} MB· αυτό το όριο: {percent}% @@ -3954,5 +4056,28 @@ curves Smooth Εξομάλυνση DDS Options Επιλογές DDS Generate Mipmaps Δημιουργία mipmap +# Color Table (.act) dialog + There is no color table to save. Δεν υπάρχει πίνακας χρωμάτων για αποθήκευση. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Αποθηκεύτηκε {n} χρώμα στο {file}|Αποθηκεύτηκαν {n} χρώματα στο {file} + # Graphics Adapter preference (#1885) GPU adapter Προσαρμογέας GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Επιβεβαιώστε ή ακυρώστε πρώτα το ανοιχτό παράθυρο βήματος της ενέργειας + Pro Medium Gray Pro μεσαίου γκρι + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro ανοιχτού γκρι + Pro Light Φωτεινό Pro + Resize Windows to Fit Προσαρμογή μεγέθους παραθύρων + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Αυτόματη κατεύθυνση παραγράφου + Left-to-right paragraph direction Κατεύθυνση παραγράφου από αριστερά προς τα δεξιά + Right-to-left paragraph direction Κατεύθυνση παραγράφου από δεξιά προς τα αριστερά + Not rendered: this adjustment ({key}) is kept as imported. Δεν αποδίδεται: αυτή η προσαρμογή ({key}) διατηρείται όπως εισήχθη. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8629fc3fda..e0cf3a62e9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -12,6 +12,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opciones de máscara… + Layer Mask Display Options… Opciones de visualización de máscara de capa… + # Channel Options and New Spot Channel (#3761) Channel Color Color del canal Color Indicates: El color indica: @@ -330,6 +334,7 @@ Blending Options… Opciones de fusión… Bevel & Emboss… Bisel y relieve… Use Global Light Usar luz global + Overprint Sobreimprimir Inner Shadow… Sombra interior… Inner Glow… Resplandor interior… Satin… Satinado… @@ -1094,7 +1099,7 @@ Done Hecho Dots Puntos Drag a rectangle around the object Arrastre un rectángulo alrededor del objeto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho para eliminar. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arrastre sobre la imagen para añadir una restricción; Mayús para horizontal/vertical; clic derecho en una restricción para cambiarla o eliminarla. Drag to move the active shape's path or the Work Path Arrastre para mover el trazado de la forma activa o el trazado de trabajo Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleccionar punto · Arrastrar: mover punto, manejador o segmento · {shift}-clic: añadir · {alt}-clic: subtrazado completo Draw with the Pen tool (P) or make a work path from a selection. Dibuje con la herramienta Pluma (P) o cree un trazado de trabajo a partir de una selección. @@ -1336,6 +1341,8 @@ Override Pressure Anular presión Override Rotation Anular rotación Override Tilt Anular inclinación + Override Tilt X Anular inclinación X + Override Tilt Y Anular inclinación Y PRESET DETAILS DETALLES DEL AJUSTE PREESTABLECIDO PSD PSD Paint Overlay Superposición de pintura @@ -1488,6 +1495,7 @@ Set shape fill type Establecer el tipo de relleno de la forma Set shape stroke type Establecer el tipo de trazo de la forma Set the text color Establecer el color del texto + Create warped text Crear texto deformado Shadows Sombras Shape: Forma: Sharpening Enfoque @@ -1995,6 +2003,7 @@ Quick export format Formato de exportación rápida Quick export location Ubicación de exportación rápida Recent file count Número de archivos recientes + LUT folder Carpeta de LUT Recent files Archivos recientes Recent fonts Fuentes recientes Recover on launch Recuperar al iniciar @@ -2325,6 +2334,8 @@ GPU acceleration could not continue. Your documents are unchanged. La aceleración GPU no pudo continuar. Los documentos no han cambiado. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft compone las imágenes en la CPU. La ventana aún puede usar el adaptador gráfico. Retry GPU requires a restart. Save your work first. Reintentar la GPU requiere reiniciar. Guarde primero su trabajo. + Retry GPU tries the graphics processor again now. Reintentar la GPU vuelve a probar el procesador gráfico ahora. + GPU acceleration resumed. Se reanudó la aceleración GPU. Keep Using CPU Seguir usando CPU Retry GPU Reintentar GPU Advanced Avanzado @@ -2385,6 +2396,11 @@ cameraRaw Amount Cantidad Content-Aware Move Tool Herramienta Mover con detección de contenido TIFF Options Opciones de TIFF Targa Options Opciones de Targa + JPEG Options Opciones de JPEG + Image Options Opciones de imagen + Format Options Opciones de formato + Baseline ("Standard") Línea de base ("Estándar") + Baseline Optimized Línea de base optimizada 24 bits/pixel 24 bits/píxel 32 bits/pixel 32 bits/píxel Including layers will increase file size. Incluir las capas aumentará el tamaño del archivo. @@ -2738,6 +2754,35 @@ cameraData Lens Objetivo Mirror Reflejar Output Settings Ajustes de salida Output To Salida a + + Animation Options Opciones de animación + Entire Document Documento entero + Filter Options Opciones de filtro + Layer Bounds Límites de capa + Layers Panel Options Opciones del panel Capas + Lock Layers Bloquear capas + New Artboard… Nueva mesa de trabajo… + New Group… Nuevo grupo… + New Layer… Nueva capa… + Panel Options… Opciones de panel… + Prevent Auto-Nesting Impedir anidación automática + Show Layers Mostrar capas + Thumbnail Contents Contenido de miniaturas + Thumbnail Size Tamaño de miniaturas + Use Default Masks on Fill Layers Usar máscaras predeterminadas en capas de relleno + Expand New Effects Expandir nuevos efectos + Add “copy” to Copied Layers and Groups Añadir «copia» a las capas y grupos duplicados + Preview on canvas when reordering layers Previsualizar en el lienzo al reordenar capas + Show layer mask badges Mostrar insignias de máscaras de capa + +# Smart Filter editors + Blending Options ({filter}) Opciones de fusión ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ocultar efectos de capa y filtros inteligentes ({key}-clic: todas las capas) + Show the layer's effects and Smart Filters ({key}-click: all layers) Mostrar efectos de capa y filtros inteligentes ({key}-clic: todas las capas) + Wait for the current job to finish. Espere a que termine la tarea actual. + The document or Smart Filter changed. Reopen its dialog. El documento o el filtro inteligente ha cambiado. Vuelva a abrir su diálogo. + Opacity must be between 0% and 100%. La opacidad debe estar entre 0% y 100%. + Dissolve is approximated: it blends without Photoshop's dither. Disolver es aproximado: se fusiona sin el tramado de Photoshop. Loading fonts… Cargando fuentes… Response curve Curva de respuesta Tool Herramienta @@ -2785,6 +2830,10 @@ curves Smooth Suavizar Click to place · Alt-click or drag off-canvas to remove Clic para colocar · Alt-clic o arrastrar fuera del lienzo para quitar No document open Ningún documento abierto {n} of {max} sample points {n} de {max} puntos de muestreo + +# History panel menu (#2966) + New Snapshot… Nueva instantánea… + History Options… Opciones de historial… # Print dialog (#2697) Emulsion Down Emulsión hacia abajo Functions Funciones @@ -2819,15 +2868,21 @@ curves Smooth Suavizar Light Gray Gris claro Object Finder Buscador de objetos No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels La capa activa no tiene píxeles aquí; activa Muestrear todas las capas o selecciona una capa con píxeles Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + Load Contours… Cargar contornos… + Replace Contours… Reemplazar contornos… + Save Contours… Guardar contornos… + Delete Contour Eliminar contorno + Loaded contours from {file} Contornos cargados de {file} + Saved contours to {file} Contornos guardados en {file} # Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the # other catalogs already translate @id select.all Seleccionar todo Delete Smart Filters Eliminar filtros inteligentes Enable Smart Filters Activar filtros inteligentes - New Layer… Nueva capa… Delete Layer… Eliminar capa… Select Similar Colors Seleccionar colores similares Canvas rotation Rotación del lienzo @@ -2904,7 +2959,6 @@ curves Smooth Suavizar Automatically Create New Snapshot When Saving Crear automáticamente una instantánea nueva al guardar Show New Snapshot Dialog by Default Mostrar de forma predeterminada el cuadro de diálogo Nueva instantánea Clear History Borrar historial - Panel Options… Opciones de panel… View menu Menú Vista Keep view in sync Mantener la vista sincronizada {zoom}% {zoom}% @@ -2956,6 +3010,7 @@ curves Smooth Suavizar Clipping Path Trazado de recorte New Path Nuevo trazado Duplicate Path Duplicar trazado + Rename Path… Cambiar nombre del trazado… Auto-Hide Ocultar automáticamente X X Y Y @@ -3010,6 +3065,8 @@ curves Smooth Suavizar Keywords Palabras clave Copyright Info URL URL de información de copyright Copyright Status Estado del copyright + Copyright Notice Aviso de copyright + Document Title Título del documento Copyrighted Con copyright Public Domain Dominio público Unknown Desconocido @@ -3815,7 +3872,6 @@ layerLabel No Color Sin color Lens Flare Destello Lighting Effects Efectos de iluminación Liquify Licuar - Lock Layers Bloquear capas Lum Lum Match Color Igualar color Merge to HDR Pro Combinar en HDR Pro @@ -4106,6 +4162,9 @@ navigator Zoom Zoom # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Cargar acciones… Save Actions… Guardar acciones… + New Action… Nueva acción… + Start Recording Iniciar grabación + Stop Recording Detener grabación {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Se cargó {n} acción de {file}|Se cargaron {n} acciones de {file} @@ -4136,6 +4195,16 @@ navigator Zoom Zoom Sent to {printer} Enviado a {printer} set in Print Settings… se establece en Ajustes de impresión… ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles +# History panel › History Brush source (#2966) + Paints from the History panel's source state Pinta a partir del estado de origen del panel Historia + Sets the source for the history brush Establece el origen del pincel de historia +# Bevel & Emboss shading controls. + Highlight Mode Modo de luces + Highlight Color Color de luces + Highlight Opacity Opacidad de luces + Shadow Mode Modo de sombras + Shadow Color Color de sombras + Shadow Opacity Opacidad de sombras # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Usar el perfil incrustado ({profile}) Convert to the working space ({profile}) Convertir al espacio de trabajo ({profile}) @@ -4152,11 +4221,15 @@ navigator Zoom Zoom Chisel Hard Cincelar duro Chisel Soft Cincelar suave Open color-less EXR as ACEScg Abrir EXR sin espacio de color como ACEScg + Do not append "copy" to filename when saving a copy No añadir «copy» al nombre de archivo al guardar una copia # Export As metadata and profile options # View › Guides › right-click a guide Edit Selected Guides Editar guías seleccionadas Clear Selected Guides Borrar guías seleccionadas + Bicubic Automatic Bicúbica automática + Bicubic Smoother Bicúbica más suavizada + Bicubic Sharper Bicúbica más enfocada Pasting from other apps is unavailable Pegar desde otras aplicaciones no está disponible PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft no puede acceder al portapapeles del sistema en esta sesión. En Wayland, el entorno aislado de Flatpak oculta el protocolo con el que se leen las imágenes de otras aplicaciones. To paste from other apps, start PhotoCraft under XWayland: `{command}` Para pegar desde otras aplicaciones, inicia PhotoCraft en XWayland: `{command}` @@ -4176,6 +4249,17 @@ navigator Zoom Zoom set name nombre del conjunto {set} (modified) {set} (modificado) Show file name in title Mostrar nombre de archivo en el título + +# Image › Mode › Bitmap (#5559) + 50% Threshold Umbral 50% + Pattern Dither Tramado de motivo + Diffusion Dither Tramado de difusión + Halftone Screen Trama de semitonos + Custom Pattern Motivo personalizado + Frequency: Frecuencia: + Lines/Inch Líneas/pulgada + Lines/Centimeter Líneas/centímetro + Ellipse Elipse Accept and Go to Conflict Aceptar e ir al conflicto Changes undone. Cambios deshechos. Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno. @@ -4193,15 +4277,33 @@ navigator Zoom Zoom GPU Memory Full Memoria de GPU llena The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Las capas necesitan {needed} de memoria de GPU y pueden ocupar {budget}, así que las ediciones que redibujan todo el documento lo hacen en la CPU. Haga clic para ajustar Memoria de GPU. GPU memory budget: {budget} Presupuesto de memoria de GPU: {budget} + Graphics memory: {size} Memoria gráfica: {size} This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) + {path}: the path is not valid Unicode; save under another name. {path}: la ruta no es Unicode válido; guarde con otro nombre. {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. Decrease Brush Size Reducir tamaño del pincel Increase Brush Size Aumentar tamaño del pincel Decrease Brush Hardness Reducir dureza del pincel Increase Brush Hardness Aumentar dureza del pincel + Demosaic Demosaico + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cómo se completa el mosaico del filtro de color de la cámara. Se aplica al abrir el archivo; la vista previa muestra el método con el que se abrió. + AHD (adaptive homogeneity) AHD (homogeneidad adaptativa) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: los bordes más limpios y el menor halo de color; el más lento. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolación con corrección de gradiente: nítida y rápida, con algo de halo de color en el detalle fino. + The fastest and the softest, with colour fringing on fine detail. El más rápido y el más suave, con halo de color en el detalle fino. + “{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo? + The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}». + Move Up Mover arriba + Move Down Mover abajo + Previous Brush Pincel anterior + Next Brush Pincel siguiente + First Brush Primer pincel + Last Brush Último pincel + Preview size Preview size Memory Usage Uso de memoria Per-document RAM limit (MB) Límite de RAM por documento (MB) Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este límite: {percent}% @@ -4216,5 +4318,28 @@ navigator Zoom Zoom DDS Options Opciones de DDS Generate Mipmaps Generar mipmaps +# Color Table (.act) dialog + There is no color table to save. No hay ninguna tabla de colores para guardar. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Se guardó {n} color en {file}|Se guardaron {n} colores en {file} + # Graphics Adapter preference (#1885) GPU adapter Adaptador de GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Confirme o cancele primero el cuadro de diálogo abierto del paso de la acción + Pro Medium Gray Pro (gris medio) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (gris claro) + Pro Light Pro (claro) + Resize Windows to Fit Redimensionar ventanas para ajustar + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Dirección de párrafo automática + Left-to-right paragraph direction Dirección de párrafo de izquierda a derecha + Right-to-left paragraph direction Dirección de párrafo de derecha a izquierda + Not rendered: this adjustment ({key}) is kept as imported. No se renderiza: este ajuste ({key}) se conserva tal como se importó. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fad669a4b2..b6df1c135b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -19,6 +19,10 @@ # ArtCraft, OpenType, Lab ...) stay as they are; RGB/CMYK read RVB/CMJN; the trailing "…" of # dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Options de masque… + Layer Mask Display Options… Options d'affichage du masque de calque… + # Channel Options and New Spot Channel (#3761) Channel Color Couleur de la couche Color Indicates: La couleur indique : @@ -1080,7 +1084,7 @@ Done Terminé Dots Points Drag a rectangle around the object Tracez un rectangle autour de l'objet - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Faites glisser sur l'image pour ajouter une contrainte ; Maj pour horizontal/vertical ; clic droit pour supprimer. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Faites glisser sur l'image pour ajouter une contrainte ; Maj pour horizontal/vertical ; clic droit sur une contrainte pour la modifier ou la supprimer. Drag to move the active shape's path or the Work Path Faites glisser pour déplacer le tracé de la forme active ou le tracé de travail Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic : sélectionner le point · Glisser : déplacer point, poignée ou segment · {shift}-clic : ajouter · {alt}-clic : sous-tracé entier Draw with the Pen tool (P) or make a work path from a selection. Dessinez avec l'outil Plume (P) ou créez un tracé de travail à partir d'une sélection. @@ -1316,6 +1320,8 @@ Override Pressure Forcer la pression Override Rotation Forcer la rotation Override Tilt Forcer l'inclinaison + Override Tilt X Forcer l'inclinaison X + Override Tilt Y Forcer l'inclinaison Y PRESET DETAILS DÉTAILS DU PRÉRÉGLAGE PSD PSD Paint Overlay Superposition de peinture @@ -1468,6 +1474,7 @@ Set shape fill type Définir le type de remplissage de la forme Set shape stroke type Définir le type de contour de la forme Set the text color Définir la couleur du texte + Create warped text Créer un texte déformé Shadows Ombres Shape: Forme : Sharpening Netteté @@ -1964,6 +1971,7 @@ Quick export format Format d'exportation rapide Quick export location Emplacement d'exportation rapide Recent file count Nombre de fichiers récents + LUT folder Dossier LUT Recent files Fichiers récents Recent fonts Polices récentes Recover on launch Récupérer au démarrage @@ -2371,6 +2379,7 @@ Clipping Path Tracé de détourage New Path Nouveau tracé Duplicate Path Dupliquer le tracé + Rename Path… Renommer le tracé… Delete Path Supprimer le tracé Auto-Hide Masquage automatique W L @@ -2436,6 +2445,8 @@ Keywords Mots-clés Copyright Info URL URL des informations de copyright Copyright Status Statut du copyright + Copyright Notice Mention de copyright + Document Title Titre du document Copyrighted Protégé par copyright Public Domain Domaine public Unknown Inconnu @@ -2656,6 +2667,7 @@ 32 bit (float) 32 bits (flottant) Floating point Virgule flottante Use Global Light Utiliser la lumière globale + Overprint Surimpression Native file drag-and-drop is unavailable Le glisser-déposer natif de fichiers n'est pas disponible Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Le glisser-déposer natif de fichiers n'est pas encore pris en charge sous Wayland. Utilisez Fichier › Ouvrir, ou copiez l'image dans votre gestionnaire de fichiers et collez-la avec {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Pour déposer des fichiers, lancez PhotoCraft sous XWayland : `{command}` @@ -2677,6 +2689,8 @@ GPU acceleration could not continue. Your documents are unchanged. L'accélération GPU n'a pas pu continuer. Vos documents sont intacts. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft utilise le compositeur d'images CPU. La fenêtre peut encore utiliser votre carte graphique. Retry GPU requires a restart. Save your work first. Réessayer le GPU nécessite un redémarrage. Enregistrez votre travail au préalable. + Retry GPU tries the graphics processor again now. Réessayer le GPU sollicite de nouveau le processeur graphique dès maintenant. + GPU acceleration resumed. Accélération GPU rétablie. Keep Using CPU Continuer avec le CPU Retry GPU Réessayer le GPU GPU Backend Moteur GPU @@ -2735,6 +2749,11 @@ cameraRaw Blending Fusion Content-Aware Move Tool Outil Déplacement selon le contenu TIFF Options Options TIFF Targa Options Options Targa + JPEG Options Options JPEG + Image Options Options d’image + Format Options Options de format + Baseline ("Standard") Ligne de base (« Standard ») + Baseline Optimized Ligne de base optimisée 24 bits/pixel 24 bits/pixel 32 bits/pixel 32 bits/pixel Including layers will increase file size. L'inclusion des calques augmentera la taille du fichier. @@ -3074,6 +3093,33 @@ cameraData Lens Objectif Output To Sortie vers Duplicate Layer Dupliquer le calque Contract Contracter + + Animation Options Options d’animation + Entire Document Document entier + Filter Options Options de filtrage + Layer Bounds Limites du calque + Layers Panel Options Options du panneau Calques + Lock Layers Verrouiller les calques + New Artboard… Nouveau plan de travail… + New Group… Nouveau groupe… + Prevent Auto-Nesting Empêcher l’imbrication automatique + Show Layers Afficher les calques + Thumbnail Contents Contenu des vignettes + Thumbnail Size Taille des vignettes + Use Default Masks on Fill Layers Utiliser les masques par défaut sur les calques de remplissage + Expand New Effects Développer les nouveaux effets + Add “copy” to Copied Layers and Groups Ajouter « copie » aux calques et groupes dupliqués + Preview on canvas when reordering layers Prévisualiser sur la zone de travail lors du réagencement des calques + Show layer mask badges Afficher les badges des masques de fusion + +# Smart Filter editors + Blending Options ({filter}) Options de fusion ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Masquer les effets du calque et les filtres dynamiques ({key}-clic : tous les calques) + Show the layer's effects and Smart Filters ({key}-click: all layers) Afficher les effets du calque et les filtres dynamiques ({key}-clic : tous les calques) + Wait for the current job to finish. Attendez la fin de la tâche en cours. + The document or Smart Filter changed. Reopen its dialog. Le document ou le filtre dynamique a changé. Rouvrez sa boîte de dialogue. + Opacity must be between 0% and 100%. L'opacité doit être comprise entre 0% et 100%. + Dissolve is approximated: it blends without Photoshop's dither. Le mode Fondu est approximé : il fusionne sans le tramage de Photoshop. Loading fonts… Chargement des polices… Response curve Courbe de réponse Tool Outil @@ -3119,6 +3165,10 @@ curves Smooth Lisser Click to place · Alt-click or drag off-canvas to remove Cliquez pour placer · Alt-clic ou glisser hors du canevas pour supprimer No document open Aucun document ouvert {n} of {max} sample points {n} sur {max} points d'échantillonnage + +# History panel menu (#2966) + New Snapshot… Nouvel instantané… + History Options… Options d'historique… # Print dialog (#2697) Emulsion Down Émulsion verso Functions Fonctions @@ -3138,9 +3188,16 @@ curves Smooth Lisser Light Gray Gris clair Object Finder Outil de recherche d’objets No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Le calque actif n’a pas de pixels ici — activez Échantillonner tous les calques ou sélectionnez un calque contenant des pixels Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Load Contours… Charger des contours… + Replace Contours… Remplacer les contours… + Save Contours… Enregistrer les contours… + Delete Contour Supprimer le contour + Loaded contours from {file} Contours chargés depuis {file} + Saved contours to {file} Contours enregistrés dans {file} Show home recent files Afficher les fichiers récents sur l'écran d'accueil Clear Brush Controls Effacer les commandes de pinceau Reset All Locked Settings Réinitialiser tous les paramètres verrouillés @@ -3149,6 +3206,9 @@ curves Smooth Lisser # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Charger des actions… Save Actions… Enregistrer les actions… + New Action… Nouvelle action… + Start Recording Démarrer l'enregistrement + Stop Recording Arrêter l'enregistrement {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} action chargée depuis {file}|{n} actions chargées depuis {file} @@ -3181,6 +3241,16 @@ curves Smooth Lisser Sent to {printer} Envoyé à {printer} set in Print Settings… défini dans les paramètres d’impression… ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels +# History panel › History Brush source (#2966) + Paints from the History panel's source state Peint à partir de l'état source du panneau Historique + Sets the source for the history brush Définit la source du pinceau d'historique +# Bevel & Emboss shading controls. + Highlight Mode Mode des tons clairs + Highlight Color Couleur des tons clairs + Highlight Opacity Opacité des tons clairs + Shadow Mode Mode des ombres + Shadow Color Couleur des ombres + Shadow Opacity Opacité des ombres # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Utiliser le profil incorporé ({profile}) Convert to the working space ({profile}) Convertir vers l'espace de travail ({profile}) @@ -3197,10 +3267,14 @@ curves Smooth Lisser Chisel Hard Ciselage dur Chisel Soft Ciselage doux Open color-less EXR as ACEScg Ouvrir les EXR sans espace colorimétrique en ACEScg + Do not append "copy" to filename when saving a copy Ne pas ajouter « copy » au nom de fichier lors de l’enregistrement d’une copie # View › Guides › right-click a guide Edit Selected Guides Modifier les repères sélectionnés Clear Selected Guides Effacer les repères sélectionnés + Bicubic Automatic Bicubique automatique + Bicubic Smoother Bicubique plus lisse + Bicubic Sharper Bicubique plus net Pasting from other apps is unavailable Le collage depuis d’autres applications est indisponible PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft ne peut pas accéder au presse-papiers du système dans cette session. Sous Wayland, le bac à sable Flatpak masque le protocole par lequel les images des autres applications sont lues. To paste from other apps, start PhotoCraft under XWayland: `{command}` Pour coller depuis d’autres applications, lancez PhotoCraft sous XWayland : `{command}` @@ -3221,6 +3295,18 @@ curves Smooth Lisser {set} (modified) {set} (modifié) Random Aléatoire Show file name in title Afficher le nom de fichier dans le titre + +# Image › Mode › Bitmap (#5559) + 50% Threshold Seuil 50 % + Pattern Dither Simulation par motif + Diffusion Dither Simulation par diffusion + Halftone Screen Trame de demi-teintes + Custom Pattern Motif personnalisé + Frequency: Fréquence : + Lines/Inch Lignes/pouce + Lines/Centimeter Lignes/centimètre + Ellipse Ellipse + Cross Croix Accept and Go to Conflict Accepter et aller au conflit Changes undone. Modifications annulées. Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci. @@ -3238,9 +3324,11 @@ curves Smooth Lisser GPU Memory Full Mémoire GPU pleine The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Les calques ont besoin de {needed} de mémoire GPU et peuvent en occuper {budget} : les modifications qui redessinent tout le document le font sur le CPU. Cliquez pour régler Mémoire GPU. GPU memory budget: {budget} Budget de mémoire GPU : {budget} + Graphics memory: {size} Mémoire graphique : {size} This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) + {path}: the path is not valid Unicode; save under another name. {path} : le chemin n’est pas un Unicode valide ; enregistrez sous un autre nom. Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. @@ -3248,6 +3336,22 @@ curves Smooth Lisser Increase Brush Size Augmenter la taille du pinceau Decrease Brush Hardness Réduire la dureté du pinceau Increase Brush Hardness Augmenter la dureté du pinceau + Demosaic Dématriçage + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Comment la mosaïque du filtre couleur de l'appareil est complétée. Appliqué à l'ouverture du fichier ; l'aperçu montre la méthode avec laquelle il a été ouvert. + AHD (adaptive homogeneity) AHD (homogénéité adaptative) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks : les bords les plus nets et le moins de franges colorées ; le plus lent. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolation corrigée par gradient : nette et rapide, avec quelques franges colorées sur les détails fins. + The fastest and the softest, with colour fringing on fine detail. Le plus rapide et le plus doux, avec des franges colorées sur les détails fins. + “{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ? + The name in the save dialog was “{name}”. Le nom dans la boîte de dialogue d'enregistrement était « {name} ». + Move Up Déplacer vers le haut + Move Down Déplacer vers le bas + Previous Brush Pinceau précédent + Next Brush Pinceau suivant + First Brush Premier pinceau + Last Brush Dernier pinceau + Preview size Preview size Memory Usage Utilisation de la mémoire Per-document RAM limit (MB) Limite RAM par document (Mo) Installed RAM: {ram} MB; this limit: {percent}% RAM installée : {ram} Mo ; cette limite : {percent}% @@ -3263,5 +3367,1059 @@ curves Smooth Lisser Compression Compression Generate Mipmaps Générer les mipmaps +# Color Table (.act) dialog + There is no color table to save. Il n’y a aucune table de couleurs à enregistrer. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} couleur enregistrée dans {file}|{n} couleurs enregistrées dans {file} + # Graphics Adapter preference (#1885) GPU adapter Adaptateur GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Validez ou annulez d’abord la boîte de dialogue ouverte de l’étape de l’action + Pro Medium Gray Pro gris moyen + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro gris clair + Pro Light Pro clair + Resize Windows to Fit Redimensionner les fenêtres + + EXR EXR + (none) (aucun) + 32-bit Preview Options Options d'aperçu 32 bits + AI Integrations Intégrations d'IA + About {app} À propos de {app} + Accented Edges Contours accentués + Action playback failed: {error} Échec de la lecture de l'action : {error} + Actual Pixels Pixels réels + Add Mask Ajouter un masque + Add Noise Ajout de bruit + Added swatch {name} Nuance {name} ajoutée + Adjust Interval For Loops Ajuster l'intervalle pour les boucles + Adjustment Layers Calques d'ajustement + Adjustment Properties Propriétés de l'ajustement + Adobe-rgb-compat Adobe RGB (compatible) + Adwaita (Dark) Adwaita (sombre) + Adwaita (Light) Adwaita (clair) + After Après + All shortcuts reset to Photoshop defaults. Tous les raccourcis ont été réinitialisés aux valeurs par défaut de Photoshop. + Ambience Ambiance + Ambient Ambiante + Amplitude Max Amplitude max. + Amplitude Min Amplitude min. + Angled Strokes Traits obliques + Anisotropic Anisotrope + Apply Data Set Appliquer un jeu de données + Apply Image Appliquer une image + Apply Mask Appliquer le masque + Apply to All Appliquer à tout + Apply {option}: {state} Appliquer {option} : {state} + Arbitrary Angle personnalisé + Arc Lower Arc inférieur + Arc Upper Arc supérieur + Are you sure you want to quit? Voulez-vous vraiment quitter ? + Are you sure? Êtes-vous sûr ? + Area Aire + Around Center Autour du centre + Arrangement Disposition + Arrows Flèches + Artboard from Group Plan de travail d'après un groupe + Artboard from Layers Plan de travail d'après des calques + Artboards Plans de travail + Artboards to Files Plans de travail en fichiers + Artboards to PDF Plans de travail en PDF + Artistic Artistique + Ask On Mismatch Demander en cas de non-concordance + Ask On Missing Demander en cas d'absence + Ask On Paste Demander au collage + Assign Profile Attribuer un profil + At À + Auto Scale Mise à l'échelle auto + Auto-Align Layers Alignement automatique des calques + Auto-Blend Layers Fusion automatique des calques + Autosave failed: {error} Échec de l'enregistrement automatique : {error} + Back Retour + Backend: {backend} ({type}) Moteur : {backend} ({type}) + Background Level Niveau d'arrière-plan + Bamboo Bambou + Bas Relief Bas-relief + Base Tree Type Type d'arbre de base + Basic Latin Latin de base + Batch Traitement par lots + Before Avant + Berries Baies + Best Optimale + Black Clip Écrêtage des noirs + Black Intensity Intensité du noir + Blade Curvature Courbure des lames + Blast Rafale + Bleach Bypass Sans blanchiment + Blend Clipped Layers as Group Fusionner les calques découpés en tant que groupe + Blend Interior Effects as Group Fusionner les effets internes en tant que groupe + Blend Text Gamma Gamma de fusion du texte + Blending Mode Mode de fusion + Bloat Dilatation + Blocks Blocs + Blue Yellow Bleu/Jaune + Border Bordure + Border Thickness Épaisseur de la bordure + Box Blur Flou par boîte + Bpc Compensation du point noir + Branches Color Couleur des branches + Branches Height Hauteur des branches + Branches Thickness Épaisseur des branches + Breeze Dark Breeze sombre + Breeze Light Breeze clair + Brick Brique + Bristle Detail Détail des poils + Browse Parcourir + Brush Detail Détail du pinceau + Brush Strokes Traits de pinceau + Brush Type Type de pinceau + Burlap Toile de jute + Calculations Calculs + Camera Raw Camera Raw + Camera Raw Filter Filtre Camera Raw + Cancel transform Annuler la transformation + Candle Light Bougie + Canvas Size Taille du canevas + Cell Size Taille des cellules + Center X Centre X + Center Y Centre Y + Chalk Area Zone de craie + Changes Saved Modifications enregistrées + Changes have not been saved. Les modifications n'ont pas été enregistrées. + Change… Modifier… + Channel1 Canal 1 + Channel2 Canal 2 + Channel3 Canal 3 + Channel4 Canal 4 + Charcoal Area Zone de fusain + Charcoal Thickness Épaisseur du fusain + Check for Updates Rechercher des mises à jour + Choose Folder Choisir un dossier + Circle Cercle + Circularity Circularité + Cleanliness Propreté + Clear Amount Zone sans lueur + Clear Selection Effacer la sélection + Clipping Mask Masque de découpe + Clipping Path… Tracé de détourage… + Clone Cloner + Clone source set at {x}, {y} Source de clonage définie en {x}, {y} + Close Window Fermer la fenêtre + Clumped Aggloméré + Coarse Dots Points grossiers + Coated-cmyk CMJN papier couché + Color Decontaminate Décontaminer les couleurs + Color Halftone Trame de demi-teintes couleur + Color Indicates Masked Areas La couleur indique les zones masquées + Color Indicates Selected Areas La couleur indique les zones sélectionnées + Color Lookup Tables Tables de correspondance des couleurs + Color Picker Sélecteur de couleurs + Color Picker (Background Color) Sélecteur de couleurs (Couleur d'arrière-plan) + Color Picker (Custom Canvas Color) Sélecteur de couleurs (Couleur personnalisée du canevas) + Color Picker (Foreground Color) Sélecteur de couleurs (Couleur de premier plan) + Color Settings Paramètres de couleur + Color Swatches Nuancier + Color Table Table des couleurs + Color management: {error} Gestion des couleurs : {error} + Colored Pencil Crayon de couleur + Commit transform Valider la transformation + Compare Comparer + Conditional Mode Change Changement de mode conditionnel + Confirm Confirmer + Constraints Contraintes + Continue Continuer + Contrasty Contrasté + Convert Convertir + Convert to Alpha Channel Convertir en canal alpha + Convert to Linked Convertir en objet lié + Convert to Profile Convertir en profil + Cool Froid + Cooling L B B Refroidissement LBB + Cooling80 Refroidissement 80 + Cooling82 Refroidissement 82 + Copy Complete Stroke Copier le contour complet + Copy Fill Copier le remplissage + Correct C A Corriger l'aberration chromatique + Correct Distortion Corriger la distorsion + Correct Vignette Corriger le vignettage + Could not rename the action: {error} Impossible de renommer l'action : {error} + Couldn't open {name}: {error} Impossible d'ouvrir {name} : {error} + Couldn't save preferences: {error}. Retrying… Impossible d'enregistrer les préférences : {error}. Nouvelle tentative… + Crack Brightness Luminosité des fissures + Crack Depth Profondeur des fissures + Crack Spacing Espacement des fissures + Craquelure Craquelure + Create By Créer par + Create Vector Mask Créer un masque vectoriel + Create… Créer… + Cross Process Traitement croisé + Crosshatch Hachures croisées + Crystallize Cristallisation + Currency Devise + Curve Darks Courbe : tons sombres + Curve Highlights Courbe : hautes lumières + Curve Lights Courbe : tons clairs + Curve Shadows Courbe : ombres + Curve Splits Courbe : points de séparation + Custom Branch Color Couleur de branche personnalisée + Custom Pixel Aspect Ratio Format des pixels personnalisé + Cutout Découpage + Cyrillic Cyrillique + Daisies Pâquerettes + Dark Gray Gris foncé + Dark Intensity Intensité des tons foncés + Dark Rough Foncé rugueux + Dark Strokes Traits sombres + Darken Only Assombrir seulement + Darkness Obscurité + Data Données + Data Set: {name} ({index}/{total}) Jeu de données : {name} ({index}/{total}) + Data Sets as Files Jeux de données en fichiers + Date Date + Date and Time Date et heure + Day For Night Nuit américaine + De-Interlace Désentrelacer + Deep Profond + Deep Blue Bleu profond + Deep Emerald Émeraude profond + Deep Red Rouge profond + Deep Yellow Jaune profond + Default Foreground and Background Colors Couleurs par défaut du premier plan et de l'arrière-plan + Default Leaves Feuilles par défaut + Default Photoshop Size Taille Photoshop par défaut + Defaults Valeurs par défaut + Define Brush Preset Définir un préréglage de pinceau + Define Custom Shape Définir une forme personnalisée + Define Pattern Définir un motif + Definition Définition + Defringe Supprimer les franges + Degrees Degrés + Delete Channel Supprimer le canal + Delete Mask Supprimer le masque + Delete… Supprimer… + Depth Map Carte de profondeur + Depth Mode Mode de profondeur + Diameter Diamètre + Diffuse Diffuser + Diffuse Glow Lueur diffuse + Direction Balance Équilibre des directions + Disable Désactiver + Disable Mask Désactiver le masque + Disable Symmetry Path Désactiver le tracé de symétrie + Disabled Désactivé + Discard Changes Ignorer les modifications + Displace Déplacement + Display Affichage + Display profile not used Profil d'affichage non utilisé + Display profiles: {reason} Profils d'affichage : {reason} + Display-p3 Display P3 + Distortion Distorsion + Distribution Distribution + Do you want to save you made to “{name}” before quitting? Voulez-vous enregistrer les modifications apportées à « {name} » avant de quitter ? + Documentation Documentation + Documents Documents + Dot Point + Double Line Double ligne + Draft Brouillon + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Tracez un cadre de recadrage · faites glisser à l'intérieur pour le déplacer · les bords le redimensionnent ({ratio} proportions, {centre} centre) · Espace le déplace pendant le tracé · {commit} valide · Échap annule + Drag: draw freehand · Release on the start point: close Glisser : tracer à main levée · Relâcher sur le point de départ : fermer + Driver: {driver} Pilote : {driver} + Dry Brush Pinceau sec + Duplicate Channel Dupliquer le canal + Duplicate Layers Dupliquer les calques + Duplication Duplication + Dust & Scratches Poussière et rayures + Edge Brightness Luminosité du contour + Edge Extension Extension du contour + Edge Fidelity Fidélité des contours + Edge Intensity Intensité du contour + Edge Only Contours seulement + Edge Simplicity Simplicité des contours + Edge Thickness Épaisseur du contour + Edge Width Largeur du contour + Edit Toolbar Modifier la barre d'outils + Edit… Modifier… + Elevation Élévation + Eliminate Éliminer + Enable Activer + Enable Mask Activer le masque + End X Fin X + End Y Fin Y + Enlarged Agrandi + Even Fields Trames paires + Exit Quick Mask Quitter le mode masque rapide + Export As Exporter sous + Export Contents Exporter le contenu + Export Image Exporter l'image + Export Options Options d'exportation + Export Preferences Préférences d'exportation + Export Settings Paramètres d'exportation + Export to Exporter vers + Export/Import Presets Exporter/importer des préréglages + Exported contents to {path} Contenu exporté vers {path} + Exported presets to {path} Préréglages exportés vers {path} + Exported {path} {path} exporté + Exported {path} ({size}) {path} exporté ({size}) + Exporting… Exportation… + Extrude Extruder + Faded Film Film délavé + Failed Échec + Failed to open file Échec de l'ouverture du fichier + Failed to save file Échec de l'enregistrement du fichier + Fallback: {reason} Repli : {reason} + Feather Radius Rayon d'adoucissement + Fiber Length Longueur des fibres + Fibers Fibres + Field Blur Flou de champ + File Format Format de fichier + File Info Informations sur le fichier + File Name Nom du fichier + File Size Taille du fichier + File not found Fichier introuvable + Filename Nom de fichier + Fill Color Couleur de remplissage + Fill Path… Remplir le tracé… + Film Grain Grain de film + Filter Gallery Galerie de filtres + Find Rechercher + Find and Replace Text Rechercher et remplacer du texte + Fine Fine + Fine Dots Points fins + Finish Terminer + Fit Image Adapter l'image + Fit to Window Adapter à la fenêtre + Flame Flamme + Flame Bottom Alignment Alignement du bas de la flamme + Flame Lines Lignes de flamme + Flame Shape Forme de la flamme + Flame Style Style de la flamme + Flame Type Type de flamme + Flat Plat + Flat Shading Ombrage plat + Flower Color Couleur des fleurs + Focal Distance Distance de mise au point + Focus Mise au point + Focus Area Zone de mise au point + Foreground Level Niveau de premier plan + Forward Warp Déformation vers l'avant + Frame Cadre + Free Transform Path Transformation libre du tracé + Freeze Lasso Lasso de figeage + Freeze Mask Masque de figeage + Fresco Fresque + From Left À partir de la gauche + From Right À partir de la droite + Frosted Dépoli + Full Screen Plein écran + Fullscreen Plein écran + Fuzziness Tolérance + GPU backend preference: {backend} Préférence de moteur GPU : {backend} + GPU device was lost; using the CPU renderer. Le périphérique GPU a été perdu ; utilisation du rendu CPU. + GPU error; using the CPU renderer. Erreur GPU ; utilisation du rendu CPU. + GPU retry scheduled Nouvelle tentative GPU planifiée + Gamma Gamma + Gaussian Blur Flou gaussien + General Blending Fusion générale + Generators Générateurs + Generic Générique + Glass Verre + Global Light Lumière globale + Gloss Brillant + Glow Amount Quantité de lueur + Glow Brightness Luminosité de la lueur + Glow Size Taille de la lueur + Glowing Edges Contours lumineux + Good Bonne + Grade Balance Balance de l'étalonnage + Grade Blending Fusion de l'étalonnage + Grade Global Étalonnage global + Grade Highlights Étalonnage des hautes lumières + Grade Midtones Étalonnage des tons moyens + Grade Shadows Étalonnage des ombres + Grain Amount Quantité de grain + Grain Roughness Rugosité du grain + Grain Size Taille du grain + Grain Type Type de grain + Graininess Grain + Grainy Dots Points granuleux + Graphics adapter Carte graphique + Graphics adapter: {adapter} Carte graphique : {adapter} + Gray Value (Maximum) Valeur de gris (maximum) + Gray Value (Mean) Valeur de gris (moyenne) + Gray Value (Median) Valeur de gris (médiane) + Gray Value (Minimum) Valeur de gris (minimum) + Gray-gamma-2.2 Gris gamma 2,2 + Greek Grec + Grid view Affichage en grille + Group from Layers Groupe d'après des calques + Groups Groupes + Grout Width Largeur du joint + Guide Layout… Disposition des repères… + HDR Toning Tonalité HDR + Hearts Cœurs + Heptagon Heptagone + Hexagon Hexagone + Hidden Masqué + Hide Grid Masquer la grille + Hide Others Masquer les autres + Hide {app} Masquer {app} + High Pass Passe-haut + Highlight Hautes lumières + Highlight Amount Quantité des hautes lumières + Highlight Area Zone des hautes lumières + Highlight Radius Rayon des hautes lumières + Highlight Strength Intensité des hautes lumières + Highlight Tone Amplitude tonale des hautes lumières + Horizontal Only Horizontal seulement + Hotspot Point chaud + Hsb TSL + Hsl TSI + Hsl Hue Teinte TSI + Hsl Lum Luminance TSI + Hsl Sat Saturation TSI + Image Balance Équilibre de l'image + Image Processor Processeur d'images + Image compositor: {processor} Compositeur d'images : {processor} + Import Brushes Importer des pinceaux + Import Image Importer une image + Import Options Options d'importation + Import Photoshop brushes (.abr) Importer des pinceaux Photoshop (.abr) + Import Settings Paramètres d'importation + Importing {what} from {file}… Importation de {what} depuis {file}… + Importing… Importation… + In Black Noir d'entrée + In White Blanc d'entrée + Infinite Infini + Information Informations + Ink Outlines Contours à l'encre + Input Entrée + Input Levels Niveaux d'entrée + Input Mode Mode d'entrée + Install Plug-in Installer une extension + Integrated Density Densité intégrée + Intensity Intensité + Intent Intention de rendu + Interpret Footage Interpréter le métrage + Interval Intervalle + Invalid panel layout; restored the default dock Disposition des panneaux non valide ; l'ancrage par défaut a été rétabli + Invert Mask Inverser le masque + Invert Selection Inverser la sélection + Iris Blur Flou d'iris + Ivy Lierre + Jag Dentelé + Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft + Join the ArtCraft Discord ({url}) Rejoindre le Discord d'ArtCraft ({url}) + Kernel Noyau + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Crénage : Métriques, Optique ou une valeur en 1/1000 em (Alt+←/→ au point d'insertion) + Keyboard Clavier + Label Étiquette + Latin 1 Latin 1 + Latin Extended-A Latin étendu A + Latin Extended-B Latin étendu B + Layer Comps to Files Compositions de calques en fichiers + Layer Content Options Options de contenu du calque + Layer From Background Calque d'après l'arrière-plan + Layer Mask Hides Effects Le masque de calque masque les effets + Layer Properties Propriétés du calque + Layer Styles Styles de calque + Layer from Background Calque d'après l'arrière-plan + Layers to Files Calques en fichiers + Leaf Color Couleur de la feuille + Learn More En savoir plus + Leaves Amount Quantité de feuilles + Leaves Color Couleur des feuilles + Leaves Size Taille des feuilles + Left Diagonal Diagonale gauche + Legacy Ancienne version + Length Longueur + Lens Blur Flou d'objectif + Lens Correction Correction de l'objectif + Lens Flare Reflet d'objectif + Less Moins + Level Based Selon les niveaux + Library Bibliothèque + Light Dark Balance Équilibre clair/foncé + Light Direction Direction de la lumière + Light Intensity Intensité de la lumière + Light Rough Clair rugueux + Light Type Type de lumière + Light X Lumière X + Light Y Lumière Y + Light Z Lumière Z + Lighten Grout Éclaircir le joint + Lighten Only Éclaircir seulement + Lighting Éclairage + Lighting Effects Effets d'éclairage + Linear Dodge Densité linéaire − + Linear-srgb sRVB linéaire + Link Lier + Liquify Liquéfier + List Liste + List view Affichage en liste + Live Preview Aperçu en direct + Load 3D LUT Charger une LUT 3D + Load Files into Stack Charger des fichiers dans une pile + Loading… Chargement… + Lock All Tout verrouiller + Lock Image Pixels Verrouiller les pixels de l'image + Lock Position Verrouiller la position + Lock Transparent Pixels Verrouiller les pixels transparents + Locked Verrouillé + Long Lines Lignes longues + Long Strokes Traits longs + Lower Inférieur + Lum Lum. + Lut LUT + Make Selection… Créer une sélection… + Make Symmetry Path Créer un tracé de symétrie + Make Work Path… Créer un tracé de travail… + Manual Manuel + Map Document Document de carte + Map Layer Calque de carte + Map Path Chemin de la carte + Mask Incomplete Blocks Masquer les blocs incomplets + Mask None Ne rien masquer + Masks Masques + Match Color Faire correspondre la couleur + Math Symbols Symboles mathématiques + Max Offset Décalage max. + Max Radius Rayon max. + Medium Dots Points moyens + Medium Gray Gris moyen + Medium Lines Lignes moyennes + Medium Strokes Traits moyens + Merge Channels… Fusionner les canaux… + Merge Spot Channel Fusionner le canal de ton direct + Merge to HDR Pro Fusionner en HDR Pro + Metallic Métallique + Mezzotint Manière noire + Middle Milieu + Midtone Contraste des tons moyens + Monitor Profile Profil du moniteur + Monitor profile in use on {display}: {profile} Profil du moniteur utilisé sur {display} : {profile} + Monitor profile in use: {profile} Profil du moniteur utilisé : {profile} + Monitor profile on {display} (main window): {profile} Profil du moniteur sur {display} (fenêtre principale) : {profile} + Monitor profile on {display}: {profile} Profil du moniteur sur {display} : {profile} + Monitor profile: {profile} Profil du moniteur : {profile} + Mono Contrast Contraste mono + Monochromatic Monochromatique + More Plus + Mosaic Mosaïque + Mosaic Tiles Carreaux de mosaïque + Motion Blur Flou de mouvement + Mouse Souris + Move Layer Déplacer le calque + Movie Prime Focale fixe cinéma + Multiple Flames Along Path Plusieurs flammes le long du tracé + Multiple Flames One Direction Plusieurs flammes dans une direction + Multiple Flames Path Directions Plusieurs flammes selon les directions du tracé + Multiple Flames Various Length Plusieurs flammes de longueurs variées + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Le glisser-déposer natif de fichiers n'est pas encore pris en charge sous Wayland. Utilisez Fichier › Ouvrir, ou lancez PhotoCraft sous XWayland avec `WAYLAND_DISPLAY= photocraft`. + Neon Glow Lueur néon + Neutralize Neutraliser + New Brush Preset Nouveau préréglage de pinceau + New Channel… Nouveau canal… + New Guide Nouveau repère + New Guide Layout Nouvelle disposition de repères + New Spot Channel… Nouveau canal de ton direct… + New Video Layer from File Nouveau calque vidéo à partir d'un fichier + New Window Nouvelle fenêtre + New document Nouveau document + Next Suivant + No Document Aucun document + No Image Aucune image + No Layers Aucun calque + No Results Aucun résultat + No Results Found Aucun résultat trouvé + No Selection Aucune sélection + No custom shape "{name}": pick one in the options bar or Window › Shapes Aucune forme personnalisée « {name} » : choisissez-en une dans la barre d'options ou via Fenêtre › Formes + No layers Aucun calque + No recent files Aucun fichier récent + No selection Aucune sélection + Noise Color Bruit de couleur + Noise Color Detail Détail du bruit de couleur + Noise Luminance Bruit de luminance + Noise Luminance Detail Détail du bruit de luminance + Nothing to Redo Rien à rétablir + Nothing to Undo Rien à annuler + Nothing to paste: the clipboard holds no image Rien à coller : le presse-papiers ne contient aucune image + Number Of Levels Nombre de niveaux + Numbers Nombres + Ocean Ripple Ondulation océanique + Octagon Octogone + Odd Fields Trames impaires + Oil Paint Peinture à l'huile + On Activé + One Flame Along Path Une flamme le long du tracé + Open As Ouvrir en tant que + Open File Ouvrir un fichier + Open Folder Ouvrir un dossier + Open Image Ouvrir une image + Open Recent is unavailable on the web La commande Ouvrir un fichier récent n'est pas disponible sur le Web + Opened {url} {url} ouvert + Opening Ouverture + Operation canceled Opération annulée + Operation cancelled Opération annulée + Operation completed Opération terminée + Operation failed Échec de l'opération + Out Black Noir de sortie + Out From Center Du centre vers l'extérieur + Out White Blanc de sortie + Out of memory Mémoire insuffisante + Output Levels Niveaux de sortie + Oval Ovale + Overlay Edge Superposition des contours + Package Rassembler + Page Setup… Mise en page… + Paint Daubs Barbouillage + Palette Knife Couteau à palette + Panel Panneau + Paper Brightness Luminosité du papier + Parallel Parallèle + Paste Complete Stroke Coller le contour complet + Paste Fill Coller le remplissage + Patchwork Patchwork + Path Blur Flou de tracé + Paths to Illustrator Tracés vers Illustrator + Pattern Type Type de motif + Pencil Width Largeur du crayon + Pentagon Pentagone + Percentage Pourcentage + Perimeter Périmètre + Permission denied Autorisation refusée + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft continue de réessayer ; tant qu'un enregistrement n'a pas abouti, les modifications des préférences seront perdues à la fermeture. + Photomerge Photomerge + Picture Frame Cadre d'image + Pinch Pincement + Pins Épingles + Place Embedded Placer incorporé + Place Linked Placer lié + Place Scale Marker Placer un repère d'échelle + Plane Plan + Planes Plans + Plastic Wrap Film plastique + Platform: {os} {arch} Plateforme : {os} {arch} + Please wait… Veuillez patienter… + Point Point + Point Curve Courbe de points + Point Curve Blue Courbe de points bleue + Point Curve Green Courbe de points verte + Point Curve Red Courbe de points rouge + Pointed Pointu + Pointillize Pointillisme + Polar Coordinates Coordonnées polaires + Polar To Rectangular Polaires vers rectangulaires + Policy Cmyk Règle CMJN + Policy Gray Règle niveaux de gris + Policy Rgb Règle RVB + Pond Ripples Ondulations d'étang + Poster Edges Contours postérisés + Posterization Postérisation + Preferences can't be saved Les préférences ne peuvent pas être enregistrées + Preferences saved Préférences enregistrées + Preferences were reset: {error} Les préférences ont été réinitialisées : {error} + Preferences… Préférences… + Preserve Conserver + Preset Manager Gestionnaire de préréglages + Press keys Appuyez sur les touches + Preview Image Aperçu de l'image + Previous Précédent + Prime105 Focale fixe 105 mm + Prime35 Focale fixe 35 mm + Printed to {path} Imprimé vers {path} + Processing… Traitement… + Profile Profil + Prophoto-compat ProPhoto (compatible) + Pucker Contraction + Push Left Pixels poussés vers la gauche + Pyramids Pyramides + Quit Quitter + Quit PhotoCraft Quitter PhotoCraft + Quit {app} Quitter {app} + Radial Blur Flou radial + Radius X Rayon X + Radius Y Rayon Y + Ran script {name} Script {name} exécuté + Randomize Length Longueur aléatoire + Randomize Shapes Formes aléatoires + Raw Defaults Valeurs Raw par défaut + Reading Lecture + Ready Prêt + Rec2020 Rec2020 + Recent Files Fichiers récents + Reconstruct All Tout reconstruire + Recovered {name} {name} récupéré + Rectangular Selection Sélection rectangulaire + Rectangular To Polar Rectangulaires vers polaires + Red Cyan Rouge/Cyan + Reduce Color Noise Réduire le bruit de couleur + Reduce Noise Réduction du bruit + Relief Relief + Relink to File Lier de nouveau au fichier + Remove All Tout supprimer + Remove Jpeg Artifact Supprimer l'artefact JPEG + Rename Brush Renommer le pinceau + Rename Channel… Renommer le canal… + Rename Group Renommer le groupe + Rename Tool Preset Renommer le préréglage d'outil + Render Video Rendu vidéo + Rendering… Rendu en cours… + Reorder Layer Réorganiser le calque + Replace Color Remplacer la couleur + Replace Contents Remplacer le contenu + Replace Footage Remplacer le métrage + Report a Bug Signaler un bogue + Report an Issue Signaler un problème + Reset All Tout réinitialiser + Reset Preferences Réinitialiser les préférences + Resolve Missing Fonts Résoudre les polices manquantes + Restart Redémarrer + Restart Required Redémarrage requis + Resume Reprendre + Retry Réessayer + Rgb RVB + Ridges Crêtes + Right Diagonal Diagonale droite + Ripple Ondulation + Ripple Magnitude Amplitude de l'ondulation + Ripple Size Taille de l'ondulation + Rope Corde + Roses Roses + Rough Pastels Pastels rugueux + Row Order Ordre des lignes + Sample Size Taille de l'échantillon + Sandstone Grès + Sat Sat. + Save As Enregistrer sous + Save Changes Enregistrer les modifications + Save File Enregistrer le fichier + Save Image Enregistrer l'image + Save Path Enregistrer le tracé + Save a Copy Enregistrer une copie + Save before closing? Enregistrer avant de fermer ? + Save for Web (Legacy) Enregistrer pour le Web (ancien) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Enregistrez votre travail et redémarrez PhotoCraft pour réessayer l'accélération GPU. Le rendu CPU reste actif pour cette session. + Saved a copy as {path} Copie enregistrée sous {path} + Saved {path}; image assets: {n} in {dir} {path} enregistré ; ressources d'image : {n} dans {dir} + Saving… Enregistrement… + Scale Effects Mettre les effets à l'échelle + Scale Factor Facteur d'échelle + Scale Units Unités d'échelle + Scaling Échelle + Scallops Festons + Script Events Manager Gestionnaire d'événements de script + Scroll menu down Faire défiler le menu vers le bas + Scroll menu up Faire défiler le menu vers le haut + Search commands, tools and panels Rechercher des commandes, outils et panneaux + Search menus Rechercher dans les menus + Seed Valeur initiale + Select Custom Color… Choisir une couleur personnalisée… + Select Inverse Inverser la sélection + Select Layer Sélectionner le calque + Select and Mask Sélectionner et masquer + Selected at launch: {backend} Sélectionné au lancement : {backend} + Sensitivity Sensibilité + Sent to the printer ({spooler}) Envoyé à l'imprimante ({spooler}) + Sepia Sépia + Services Services + Set Colors Définir les couleurs + Set Measurement Scale Définir l'échelle de mesure + Sgray sGray + Shadow Ombres + Shadow Amount Quantité des ombres + Shadow Intensity Intensité des ombres + Shadow Radius Rayon des ombres + Shadow Tone Amplitude tonale des ombres + Shadows/Highlights Ombres/Hautes lumières + Shallow Superficiel + Shape Blur Flou selon une forme + Sharpen Amount Quantité de netteté + Sharpen Detail Détail de la netteté + Sharpen Details Détails de la netteté + Sharpen Masking Masquage de la netteté + Sharpen Radius Rayon de netteté + Shear Cisaillement + Shine Brillance + Short Lines Lignes courtes + Short Strokes Traits courts + Shortcut Raccourci + Shortcuts Raccourcis + Show All Tout afficher + Show Rulers Afficher les règles + Simple Simple + Sine Sinus + Sketch Esquisse + Skip Ignorer + Slice Options Options de tranche + Smart Blur Flou intelligent + Smart Sharpen Netteté intelligente + Smoothness Lissage + Smudge Stick Estompe + Snap to Grid Aimanter à la grille + Snowflakes Flocons de neige + Softness Douceur + Software adapter Adaptateur logiciel + Solarized Dark Solarized sombre + Solid Color Couleur unie + Solid Front Faces Faces avant pleines + Source Layer Calque source + Sparkle Étincelle + Spatter Éclaboussures + Speckle Moucheté + Speed Vitesse + Spherize Sphérisation + Spin Rotation + Spin Blur Flou de rotation + Split Channels Séparer les canaux + Spot Spot + Spray Radius Rayon de pulvérisation + Sprayed Strokes Traits pulvérisés + Sprinkles Saupoudré + Square Size Taille des carrés + Squareness Forme carrée + Stagger Décalage + Stained Glass Vitrail + Star Étoile + Stars Étoiles + Start X Début X + Start Y Début Y + Statistics Statistiques + Step {step} ({id}) failed: {error} Échec de l'étape {step} ({id}) : {error} + Stippled Pointillé + Stops Arrêts + Strength Intensité + Stretch Étirement + Stroke Color Couleur du contour + Stroke Detail Détail du trait + Stroke Direction Direction du trait + Stroke Length Longueur du trait + Stroke Pressure Pression du trait + Stroke Size Taille du trait + Stroke Width Largeur du trait + Strokes Traits + Stylization Stylisation + Subtract Shapes at Overlap Soustraire les formes au chevauchement + Sumi-e Sumi-e + Surface Blur Flou de surface + Switch Foreground and Background Colors Permuter les couleurs de premier plan et d'arrière-plan + Tablet Tablette + Taper Effilement + Target Cible + Target X Cible X + Target Y Cible Y + Teal Orange Sarcelle et orange + Temporary Temporaires + Text Layer Calque de texte + Texture Coverage Couverture de la texture + Texturizer Texturisation + Thaw Mask Masque de libération + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Le canevas sur {display} est affiché en sRVB. Choisissez un profil de moniteur dans Édition › Paramètres de couleur. + The current gradient (pick one in Window › Gradients) Le dégradé actuel (à choisir dans Fenêtre › Dégradés) + The document does not have an embedded colour profile. Working space: {working}. Le document ne contient aucun profil colorimétrique incorporé. Espace de travail : {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Le document contient un profil colorimétrique incorporé qui ne correspond pas à l'espace de travail.\nIncorporé : {embedded}\nEspace de travail : {working} + The file could not be opened. Impossible d'ouvrir le fichier. + The file could not be saved. Impossible d'enregistrer le fichier. + Thickness Épaisseur + This Layer: Ce calque : + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Cet objet intelligent doit être rastérisé pour continuer. Son contenu ne sera plus modifiable. Rastériser l'objet intelligent ? + This adjustment ({key}) is kept as imported and can't be edited yet. Cet ajustement ({key}) est conservé tel qu'il a été importé et ne peut pas encore être modifié. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ce calque de remplissage doit être rastérisé pour continuer. Son contenu de remplissage ne sera plus modifiable. Rastériser le calque ? + This layer must be rasterized before proceeding. Ce calque doit être rastérisé pour continuer. + This session: {reason} Cette session : {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ce calque de forme doit être rastérisé pour continuer. Il ne sera plus modifiable en tant que forme. Rastériser la forme ? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ce calque de texte doit être rastérisé pour continuer. Son texte ne sera plus modifiable. Rastériser le texte ? + Tile Size Taille des carreaux + Tiles Carreaux + Tilt-Shift Inclinaison-décalage + Tint Color Couleur de la nuance + Tiny Lens Petites lentilles + To À + To Center Vers le centre + Toggle the Brush Settings panel Afficher/masquer le panneau Paramètres du pinceau + Tool Options Options d'outil + Trace Contour Tracer les contours + Transformation Transformation + Transition Transition + Transparency Shapes Layer La transparence détermine la forme du calque + Trap Recouvrement + Tree Arbre + Trim Rogner les bords + Turbulent Turbulent + Twirl Tourbillon + Twirl Clockwise Tourbillon horaire + Twirl Counterclockwise Tourbillon antihoraire + Unaltered Inchangé + Undefined Areas Zones non définies + Underlying Layer: Calque sous-jacent : + Underpainting Sous-couche + Underwater Sous l'eau + Ungroup Dissocier + Uniform Uniforme + Unite Shapes at Overlap Réunir les formes au chevauchement + Unlink Délier + Unlock Déverrouiller + Unlocked Déverrouillé + Unsaved Changes Modifications non enregistrées + Unsharp Mask Masque flou + Unsupported file format Format de fichier non pris en charge + Unsupported format Format non pris en charge + Up Haut + Update Mettre à jour + Updates Mises à jour + Upper Supérieur + Use Custom Color Utiliser une couleur personnalisée + Use File › Open to open an image. Utilisez Fichier › Ouvrir pour ouvrir une image. + Use Selection In Source Utiliser la sélection dans la source + Use Selection In Target Utiliser la sélection dans la cible + Vanishing Point Point de fuite + Variable Data Sets Jeux de données variables + Vector Mask Hides Effects Le masque vectoriel masque les effets + Vertical Only Vertical seulement + Vertical Type Tool Outil Texte vertical + Video Frames to Layers Images vidéo dans des calques + Vignette Amount Quantité de vignettage + Vignette Feather Adoucissement du vignettage + Vignette Highlights Hautes lumières du vignettage + Vignette Midpoint Point médian du vignettage + Vignette Roundness Arrondi du vignettage + Vignette Style Style du vignettage + Vine Color Couleur de la liane + Vine With Flowers Liane avec fleurs + Vine With Leaves Liane avec feuilles + Violent Violent + WIA Support Prise en charge de WIA + Warm Chaud + Warming L B A Réchauffement LBA + Warming81 Réchauffement 81 + Warming85 Réchauffement 85 + Warmth Chaleur + Warning Avertissement + Warp Text Déformer le texte + Watercolor Aquarelle + Wavelength Max Longueur d'onde max. + Wavelength Min Longueur d'onde min. + Waves Vagues + Wetness Humidité + White Clip Écrêtage des blancs + White Intensity Intensité du blanc + White Is High Le blanc est le plus haut + Wide Blurry Large et flou + Wide Sharp Large et net + Wind Vent + Window renderer: {renderer} Moteur de rendu de la fenêtre : {renderer} + Working Espace de travail + Working Cmyk CMJN de travail + Working Gray Gris de travail + Working Rgb RVB de travail + Workspaces Espaces de travail + Wrap Enrouler + ZigZag Zigzag + Zigzag Zigzag + auto auto + blue bleu + brushes pinceaux + fallback repli + gradients dégradés + gray gris + green vert + layer mask masque de calque + manual manuel + none aucun + off désactivé + on activé + orange orange + previous reading kept; the last re-read failed: {reason} lecture précédente conservée ; la dernière relecture a échoué : {reason} + red rouge + unknown inconnu + unknown type type inconnu + vector mask masque vectoriel + violet violet + yellow jaune + {index} of {total} {index} sur {total} + {label} is not available: {reason} {label} n'est pas disponible : {reason} + {message} (notes: {n}; {warning}) {message} (remarques : {n} ; {warning}) + {shortcut} assigned. {shortcut} attribué. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} est déjà utilisé par {commands} et lui sera retiré lorsque vous cliquerez sur OK. + {verb} {mask} {layer} {verb} le {mask} ({layer}) + ✓ Color Indicates Masked Areas ✓ La couleur indique les zones masquées + ✓ Color Indicates Selected Areas ✓ La couleur indique les zones sélectionnées +@id edit.clear Effacer +@id edit.stroke Contourner… +@id edit.transform.scale Mise à l'échelle +@id file.close Fermer +@id filter.sharpen.sharpen Netteté +@id layer.layerMask.apply Appliquer le masque de calque +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} nuance exportée vers {file}|{n} nuances exportées vers {file} +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pinceau importé depuis {file}|{n} pinceaux importés depuis {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pinceau importé depuis {group}|{n} pinceaux importés depuis {group} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} dégradé importé depuis {file}|{n} dégradés importés depuis {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} nuance importée depuis {file}|{n} nuances importées depuis {file} +@plural Played {n} step|Played {n} steps {n} étape lue|{n} étapes lues +@plural Saved for Web: {n} file|Saved for Web: {n} files Enregistré pour le Web : {n} fichier|Enregistré pour le Web : {n} fichiers +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Mise à jour de {n} objet intelligent lié modifié sur le disque|Mise à jour de {n} objets intelligents liés modifiés sur le disque +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} calque enregistré par cette composition a été supprimé. Mettez la composition à jour ou effacez l'avertissement.|{n} calques enregistrés par cette composition ont été supprimés. Mettez la composition à jour ou effacez l'avertissement. +@plural {n} plane|{n} planes {n} plan|{n} plans +@plural {n} preset|{n} presets {n} préréglage|{n} préréglages +@plural {n} sample point removed|{n} sample points removed {n} point d'échantillonnage supprimé|{n} points d'échantillonnage supprimés +@plural {n} slice|{n} slices {n} tranche|{n} tranches +@plural {n} step|{n} steps {n} étape|{n} étapes +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} autre avertissement de préréglage de pinceau)|{warning} (+{n} autres avertissements de préréglages de pinceau) +@plural {what} with {n} warning|{what} with {n} warnings {what} avec {n} avertissement|{what} avec {n} avertissements +cameraRaw Aqua Aqua +cameraRaw Balance Balance +cameraRaw Blue Bleu +cameraRaw Clarity Clarté +cameraRaw Color Couleur +cameraRaw Color Detail Détail des couleurs +cameraRaw Color Grading Étalonnage des couleurs +cameraRaw Color Mixer Mélangeur de couleurs +cameraRaw Contrast Contraste +cameraRaw Curve Courbe +cameraRaw Dehaze Correction de la brume +cameraRaw Detail Détail +cameraRaw Effects Effets +cameraRaw Exposure Exposition +cameraRaw Feather Adoucissement +cameraRaw Global Global +cameraRaw Green Vert +cameraRaw Highlights Hautes lumières +cameraRaw Hue Teinte +cameraRaw Luminance Luminance +cameraRaw Luminance Detail Détail de la luminance +cameraRaw Magenta Magenta +cameraRaw Midpoint Point médian +cameraRaw Midtones Tons moyens +cameraRaw Orange Orange +cameraRaw Purple Violet +cameraRaw Radius Rayon +cameraRaw Red Rouge +cameraRaw Roughness Rugosité +cameraRaw Roundness Arrondi +cameraRaw Saturation Saturation +cameraRaw Shadows Ombres +cameraRaw Size Taille +cameraRaw Temperature Température +cameraRaw Texture Texture +cameraRaw Vibrance Vibrance +cameraRaw Whites Blancs +cameraRaw Yellow Jaune +colorRange Invert Inverser +document Orientation Orientation +layerLabel Magenta Magenta +layerLabel No Color Aucune couleur +layerLabel Red Rouge +layers Fill Remplissage +layers Fill: Remplissage : +navigator Zoom Zoom +shape Appearance Apparence + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Sens du paragraphe automatique + Left-to-right paragraph direction Sens du paragraphe de gauche à droite + Right-to-left paragraph direction Sens du paragraphe de droite à gauche + Not rendered: this adjustment ({key}) is kept as imported. Non rendu : ce réglage ({key}) est conservé tel qu'importé. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv new file mode 100644 index 0000000000..bdd224c440 --- /dev/null +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -0,0 +1,536 @@ +# Hindi (hi) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Hindi computing and image-editing vocabulary. No proprietary localisation resource, string +# table or product UI was consulted or copied; add entries the same way. Product and technology +# names (PhotoCraft, ArtCraft, OpenType, RGB, CMYK, Lab, PSD, PNG, JPEG, WebP, OpenEXR, AVIF …) +# stay as they are; the trailing "…" of dialog commands is kept. Plural rule: one / other +# (`plural_one_other` in i18n/mod.rs). + +# Top-level Menus + File फ़ाइल + Edit एडिट + Image इमेज + Layer लेयर + Type टाइप + Select चयन + Filter फ़िल्टर + 3D 3डी + View व्यू + Window विंडो + Help सहायता + +# File Menu + New… नया… + Open… खोलें… + Open Recent हाल ही में खोले गए + Close बंद करें + Close All सभी बंद करें + Close Others अन्य सभी बंद करें + Save सहेजें + Save As… इस रूप में सहेजें… + Save a Copy… एक प्रति सहेजें… + Revert मूल स्थिति में लाएं + Export एक्सपोर्ट करें + Export As… इस रूप में एक्सपोर्ट करें… + Quick Export as PNG PNG के रूप में त्वरित एक्सपोर्ट + Import इम्पोर्ट करें + File Info… फ़ाइल जानकारी… + Print… प्रिंट करें… + Print One Copy एक प्रति प्रिंट करें + Exit बाहर निकलें + +# Edit Menu + Undo पूर्ववत करें + Redo फिर से करें + Cut काटें + Copy कॉपी करें + Copy Merged मर्ज की गई कॉपी + Paste पेस्ट करें + Paste Special विशेष रूप में पेस्ट करें + Paste in Place स्थान पर पेस्ट करें + Paste Into अंदर पेस्ट करें + Paste Outside बाहर पेस्ट करें + Clear साफ़ करें + Fill… भरें… + Stroke… स्ट्रोक… + Free Transform मुक्त रूपांतरण + Transform रूपांतरण करें + Scale आकार बदलें + Rotate घुमाएँ + Skew तिरछा करें + Distort विरूपित करें + Perspective परिप्रेक्ष्य + Warp मोड़ें + Flip Horizontal क्षैतिज पलटें + Flip Vertical लंबवत पलटें + Preferences प्राथमिकताएं + General… सामान्य… + Interface… इंटरफ़ेस… + Workspace… कार्यस्थान… + Tools… टूल्स… + Performance… परफॉरमेंस… + +# Image Menu + Mode मोड + RGB Color RGB रंग + CMYK Color CMYK रंग + Grayscale ग्रेस्केल + Lab Color Lab रंग + 8 Bits/Channel 8 बिट्स/चैनल + 16 Bits/Channel 16 बिट्स/चैनल + 32 Bits/Channel 32 बिट्स/चैनल + Adjustments समायोजन + Brightness/Contrast… चमक/कंट्रास्ट… + Levels… स्तर… + Curves… कर्व्स… + Exposure… एक्सपोज़र… + Vibrance… वाइब्रेंस… + Hue/Saturation… ह्यू/सैचुरेशन… + Color Balance… कलर बैलेंस… + Black & White… ब्लैक एंड व्हाइट… + Photo Filter… फ़ोटो फ़िल्टर… + Invert इन्वर्ट + Posterize… पोस्टराइज़ करें… + Threshold… थ्रेशहोल्ड… + Image Size… इमेज साइज़… + Canvas Size… कैनवास साइज़… + Image Rotation इमेज रोटेशन + 180° 180° + 90° Clockwise 90° दक्षिणावर्त + 90° Counter Clockwise 90° वामावर्त + Arbitrary… इच्छानुसार… + Crop क्रॉप करें + Trim… ट्रिम करें… + Reveal All सभी दिखाएँ + Duplicate… डुप्लीकेट… + +# Layer Menu + New नया + Layer… लेयर… + Layer from Background बैकग्राउंड से लेयर + Group… समूह… + Group from Layers… लेयर्स से समूह… + Artboard… आर्टबोर्ड… + Duplicate Layer… लेयर को डुप्लीकेट करें… + Delete हटाएँ + Delete Layer लेयर हटाएँ + Delete Hidden Layers छिपी हुई लेयर्स हटाएँ + Rename Layer… लेयर का नाम बदलें… + Layer Style लेयर स्टाइल + Blending Options… ब्लेंडिंग विकल्प… + Smart Objects स्मार्ट ऑब्जेक्ट्स + Convert to Smart Object स्मार्ट ऑब्जेक्ट में बदलें + Layer Mask लेयर मास्क + Hide All सभी छिपाएँ + Reveal Selection चयन दिखाएँ + Hide Selection चयन छिपाएँ + From Transparency पारदर्शिता से + Apply लागू करें + Enable सक्षम करें + Disable अक्षम करें + Merge Down नीचे मर्ज करें + Merge Visible दृश्यमान मर्ज करें + Flatten Image इमेज फ़्लैट करें + +# Select Menu + All सभी + Deselect अचयनित करें + Reselect पुनः चयन करें + Inverse उल्टा करें + All Layers सभी लेयर्स + Deselect Layers लेयर्स अचयनित करें + Find Layers लेयर्स खोजें + Color Range… रंग सीमा… + Select and Mask… चयन और मास्क… + Modify संशोधित करें + Border… सीमा… + Smooth… चिकना करें… + Expand… विस्तार करें… + Contract… संकुचित करें… + Feather… फ़ेदर… + Grow बढ़ाएँ + Similar समान + Transform Selection चयन रूपांतरित करें + Load Selection… चयन लोड करें… + Save Selection… चयन सहेजें… + +# View Menu + Proof Setup प्रूफ़ सेटअप + Proof Colors प्रूफ़ रंग + Zoom In ज़ूम इन + Zoom Out ज़ूम आउट + Fit on Screen स्क्रीन पर फ़िट करें + Fit Layer on Screen स्क्रीन पर लेयर फ़िट करें + Actual Pixels वास्तविक पिक्सेल + 100% 100% + 200% 200% + Rulers रूलर्स + Snap स्नैप + Snap To पर स्नैप करें + Guides गाइड्स + Lock Guides गाइड्स लॉक करें + Clear Guides गाइड्स हटाएं + New Guide… नई गाइड… + Lock Slices स्लाइस लॉक करें + Clear Slices स्लाइस हटाएं + +# Window Menu & Panels + Arrange व्यवस्थित करें + Tile Horizontally क्षैतिज टाइल + Tile Vertically लंबवत टाइल + Consolidate All to Tabs सभी को टैब में समेकित करें + Workspace कार्यस्थान + Reset Essentials एसेंशियल्स रीसेट करें + New Workspace… नया कार्यस्थान… + Delete Workspace… कार्यस्थान हटाएँ… + Lock Workspace कार्यस्थान लॉक करें + Color रंग + Swatches स्वॉचेस + Gradients ग्रेडिएंट्स + Patterns पैटर्न्स + Properties गुण + Layers लेयर्स + Channels चैनल्स + Paths पाथ्स + History हिस्ट्री + Actions एक्शन्स + Brushes ब्रश + Brush Settings ब्रश सेटिंग्स + Navigator नेविगेटर + Info जानकारी + Timeline टाइमलाइन + Tools टूल्स + Options विकल्प + Status Bar स्टेटस बार + +# Tools (Toolbox) + Move Tool मूव टूल + Artboard Tool आर्टबोर्ड टूल + Rectangular Marquee Tool रेक्टेंगुलर मार्की टूल + Elliptical Marquee Tool एलिप्टिकल मार्की टूल + Single Row Marquee Tool सिंगल रो मार्की टूल + Single Column Marquee Tool सिंगल कॉलम मार्की टूल + Lasso Tool लैस्सो टूल + Polygonal Lasso Tool पॉलीगोनल लैस्सो टूल + Magnetic Lasso Tool मैग्नेटिक लैस्सो टूल + Object Selection Tool ऑब्जेक्ट चयन टूल + Quick Selection Tool क्विक चयन टूल + Magic Wand Tool मैजिक वैंड टूल + Crop Tool क्रॉप टूल + Perspective Crop Tool परस्पेक्टिव क्रॉप टूल + Slice Tool स्लाइस टूल + Slice Select Tool स्लाइस चयन टूल + Frame Tool फ़्रेम टूल + Eyedropper Tool आईड्रॉपर टूल + Color Sampler Tool कलर सैंपलर टूल + Ruler Tool रूलर टूल + Note Tool नोट टूल + Count Tool काउंट टूल + Spot Healing Brush Tool स्पॉट हीलिंग ब्रश टूल + Healing Brush Tool हीलिंग ब्रश टूल + Patch Tool पैच टूल + Content-Aware Move Tool कंटेंट-अवेयर मूव टूल + Red Eye Tool रेड आई टूल + Brush Tool ब्रश टूल + Pencil Tool पेंसिल टूल + Color Replacement Tool कलर रिप्लेसमेंट टूल + Mixer Brush Tool मिक्सर ब्रश टूल + Clone Stamp Tool क्लोन स्टैम्प टूल + Pattern Stamp Tool पैटर्न स्टैम्प टूल + History Brush Tool हिस्ट्री ब्रश टूल + Art History Brush Tool आर्ट हिस्ट्री ब्रश टूल + Eraser Tool इरेज़र टूल + Background Eraser Tool बैकग्राउंड इरेज़र टूल + Magic Eraser Tool मैजिक इरेज़र टूल + Gradient Tool ग्रेडिएंट टूल + Paint Bucket Tool पेंट बकेट टूल + Blur Tool ब्लर टूल + Sharpen Tool शार्पन टूल + Smudge Tool स्मज टूल + Dodge Tool डॉज टूल + Burn Tool बर्न टूल + Sponge Tool स्पंज टूल + Pen Tool पेन टूल + Freeform Pen Tool फ्रीफॉर्म पेन टूल + Curvature Pen Tool कर्वचर पेन टूल + Add Anchor Point Tool ऐड एंकर पॉइंट टूल + Delete Anchor Point Tool डिलीट एंकर पॉइंट टूल + Convert Point Tool कन्वर्ट पॉइंट टूल + Horizontal Type Tool हॉरिजोंटल टाइप टूल + Vertical Type Tool वर्टिकल टाइप टूल + Horizontal Type Mask Tool हॉरिजोंटल टाइप मास्क टूल + Vertical Type Mask Tool वर्टिकल टाइप मास्क टूल + Path Selection Tool पाथ चयन टूल + Direct Selection Tool डायरेक्ट चयन टूल + Rectangle Tool रेक्टेंगल टूल + Rounded Rectangle Tool राउंडेड रेक्टेंगल टूल + Ellipse Tool एलिप्स टूल + Triangle Tool ट्रायंगल टूल + Polygon Tool पॉलीगोन टूल + Line Tool लाइन टूल + Custom Shape Tool कस्टम शेप टूल + Hand Tool हैंड टूल + Rotate View Tool रोटेट व्यू टूल + Zoom Tool ज़ूम टूल + +# Common Dialogs and Buttons + OK ठीक है + Cancel रद्द करें + Reset रीसेट करें + Done संपन्न + Don't Save सहेजें नहीं + Yes हाँ + No नहीं + Width: चौड़ाई: + Height: ऊंचाई: + Resolution: रिज़ॉल्यूशन: + Pixels पिक्सेल + Inches इंच + Centimeters सेंटीमीटर + Millimeters मिलीमीटर + Points पॉइंट्स + Percent प्रतिशत + Opacity: ओपेसिटी: + Flow: फ्लो: + Hardness: हार्डनेस: + Size: साइज़: + Angle: एंगल: + Roundness: राउंडनेस: + Spacing: स्पेसिंग: + Color: कलर: + Name: नेम: + Presets: प्रीसेट्स: + Language भाषा + Auto स्वचालित + +# Plurals +@plural {n} layer|{n} layers {n} लेयर|{n} लेयर्स +@plural {n} item|{n} items {n} आइटम|{n} आइटम्स +@plural {n} document|{n} documents {n} दस्तावेज़|{n} दस्तावेज़ +@plural {n} pixel|{n} pixels {n} पिक्सेल|{n} पिक्सेल्स + +# Tool and Scope strings + Scope दायरा + Selected चयनित + Mixer Brush मिक्सर ब्रश + Wet गीला + Load लोड + Mix मिश्रण + Flow प्रवाह + Sample All Layers सभी लेयर्स का नमूना लें + Pattern Stamp पैटर्न स्टैम्प + Impressionist प्रभाववादी + Aligned संरेखित + Fit in View दृश्य में फ़िट करें + Camera Raw Camera Raw + +# Camera Raw +cameraRaw Amount मात्रा + Amount मात्रा +cameraRaw Aqua एक्वा + Aqua एक्वा +cameraRaw Balance संतुलन + Balance संतुलन +cameraRaw Before (Y) पहले (Y) + Before (Y) पहले (Y) +cameraRaw Blacks ब्लैक्स + Blacks ब्लैक्स +cameraRaw Blending मिश्रण + Blending मिश्रण +cameraRaw Blue नीला + Blue नीला +cameraRaw Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer}) + Camera Raw Filter ({layer}) Camera Raw फ़िल्टर ({layer}) +cameraRaw Camera Raw isn't open Camera Raw खुला नहीं है + Camera Raw isn't open Camera Raw खुला नहीं है +cameraRaw Cancel रद्द करें +cameraRaw Cancelled opening {name} {name} खोलना रद्द किया गया + Cancelled opening {name} {name} खोलना रद्द किया गया +cameraRaw Cancelled {label} {label} रद्द किया गया + Cancelled {label} {label} रद्द किया गया +cameraRaw Clarity स्पष्टता + Clarity स्पष्टता +cameraRaw Color रंग +cameraRaw Color Detail रंग विवरण + Color Detail रंग विवरण +cameraRaw Color Grading कलर ग्रेडिंग + Color Grading कलर ग्रेडिंग +cameraRaw Color Mixer कलर मिक्सर + Color Mixer कलर मिक्सर +cameraRaw Color Priority रंग प्राथमिकता + Color Priority रंग प्राथमिकता +cameraRaw Contrast कंट्रास्ट + Contrast कंट्रास्ट +cameraRaw Curve कर्व + Curve कर्व +cameraRaw Darks डार्क्स + Darks डार्क्स +cameraRaw Dehaze डीहेज़ + Dehaze डीहेज़ +cameraRaw Detail डिटेल + Detail डिटेल +cameraRaw Developing {name} {name} विकसित किया जा रहा है + Developing {name} {name} विकसित किया जा रहा है +cameraRaw Effects इफ़ेक्ट्स + Effects इफ़ेक्ट्स +cameraRaw Exposure एक्सपोज़र + Exposure एक्सपोज़र +cameraRaw Feather फ़ेदर + Feather फ़ेदर +cameraRaw Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है + Full-resolution preview unavailable पूर्ण-रिज़ॉल्यूशन पूर्वावलोकन अनुपलब्ध है +cameraRaw Global ग्लोबल + Global ग्लोबल +cameraRaw Grain ग्रेन + Grain ग्रेन +cameraRaw Green हरा + Green हरा +cameraRaw Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O) + Highlight Clipping Warning (O) हाइलाइट क्लिपिंग चेतावनी (O) +cameraRaw Highlight Priority हाइलाइट प्राथमिकता + Highlight Priority हाइलाइट प्राथमिकता +cameraRaw Highlights हाइलाइट्स + Highlights हाइलाइट्स +cameraRaw Hue ह्यू + Hue ह्यू +cameraRaw Light लाइट + Light लाइट +cameraRaw Lights लाइट्स + Lights लाइट्स +cameraRaw Load Settings… सेटिंग्स लोड करें… + Load Settings… सेटिंग्स लोड करें… +cameraRaw Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें + Load settings from a Camera Raw .xmp file Camera Raw .xmp फ़ाइल से सेटिंग्स लोड करें +cameraRaw Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं + Loaded Camera Raw settings from {file} {file} से Camera Raw सेटिंग्स लोड की गईं +cameraRaw Luminance लुमिनेंस + Luminance लुमिनेंस +cameraRaw Luminance Detail लुमिनेंस डिटेल + Luminance Detail लुमिनेंस डिटेल +cameraRaw Magenta मैजेंटा + Magenta मैजेंटा +cameraRaw Masking मास्किंग + Masking मास्किंग +cameraRaw Midpoint मिडपॉइंट + Midpoint मिडपॉइंट +cameraRaw Midtones मिडटोन्स + Midtones मिडटोन्स +cameraRaw Noise Reduction नॉइज़ रिडक्शन + Noise Reduction नॉइज़ रिडक्शन +cameraRaw OK ठीक है +cameraRaw Open खोलें + Open खोलें +cameraRaw Opened {name} {name} खोला गया + Opened {name} {name} खोला गया +cameraRaw Orange नारंगी + Orange नारंगी +cameraRaw Paint Overlay पेंट ओवरले + Paint Overlay पेंट ओवरले +cameraRaw Purple बैंगनी + Purple बैंगनी +cameraRaw Radius त्रिज्या + Radius त्रिज्या +cameraRaw Red लाल + Red लाल +cameraRaw Roughness रफनेस + Roughness रफनेस +cameraRaw Roundness राउंडनेस + Roundness राउंडनेस +cameraRaw Saturation सैचुरेशन + Saturation सैचुरेशन +cameraRaw Save Settings… सेटिंग्स सहेजें… + Save Settings… सेटिंग्स सहेजें… +cameraRaw Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें + Save these settings as a Camera Raw .xmp file इन सेटिंग्स को Camera Raw .xmp फ़ाइल के रूप में सहेजें +cameraRaw Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U) + Shadow Clipping Warning (U) शैडो क्लिपिंग चेतावनी (U) +cameraRaw Shadows शेडोज़ + Shadows शेडोज़ +cameraRaw Sharpening शार्पनिंग + Sharpening शार्पनिंग +cameraRaw Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ + Show Lab Color Readouts Lab रंग रीडआउट दिखाएँ +cameraRaw Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ + Show Red at 3 o'clock 3 बजे की स्थिति पर लाल दिखाएँ +cameraRaw Show Selected Region चयनित क्षेत्र दिखाएँ + Show Selected Region चयनित क्षेत्र दिखाएँ +cameraRaw Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ + Show Skin Tone Indicator स्किन टोन संकेतक दिखाएँ +cameraRaw Show Vectorscope वेक्टरस्कोप दिखाएँ + Show Vectorscope वेक्टरस्कोप दिखाएँ +cameraRaw Size आकार + Size आकार +cameraRaw Temperature तापमान + Temperature तापमान +cameraRaw Texture टेक्सचर + Texture टेक्सचर +cameraRaw Tint टिंट + Tint टिंट +cameraRaw Tone Histogram टोन हिस्टोग्राम + Tone Histogram टोन हिस्टोग्राम +cameraRaw Vibrance वाइब्रेंस + Vibrance वाइब्रेंस +cameraRaw Vignetting विगनेटिंग + Vignetting विगनेटिंग +cameraRaw White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार + White Balance: As Shot व्हाइट बैलेंस: शॉट के अनुसार +cameraRaw Whites व्हाइट्स + Whites व्हाइट्स +cameraRaw Yellow पीला + Yellow पीला + +# Coverage-test keys (English until translated) + Black Point Compensation Black Point Compensation + Apply At Canvas Bounds Apply At Canvas Bounds + Convert to Shape… Convert to Shape… + Decontaminate Decontaminate + New Document New Document + New Document With Mask New Document With Mask + New Layer New Layer + New Layer With Mask New Layer With Mask + Output Output + Refine Edge… Refine Edge… + Refine Strokes Refine Strokes + Selection Selection + Shift Edge Shift Edge + Smart Radius Smart Radius + Smooth Smooth + Tolerance Tolerance + Decrease Brush Size Decrease Brush Size + Increase Brush Size Increase Brush Size + Decrease Brush Hardness Decrease Brush Hardness + Increase Brush Hardness Increase Brush Hardness + Previous Brush Previous Brush + Next Brush Next Brush + First Brush First Brush + Last Brush Last Brush + Current Brush Load वर्तमान ब्रश लोड + Load Brush ब्रश लोड करें + Clean Brush ब्रश साफ़ करें + Load the brush after each stroke हर स्ट्रोक के बाद ब्रश लोड करें + Clean the brush after each stroke हर स्ट्रोक के बाद ब्रश साफ़ करें + 8 bit 8 बिट + 16 bit 16 बिट + 32 bit (float) 32 बिट (फ़्लोट) + Integer पूर्णांक + Floating point फ़्लोटिंग पॉइंट + Delete layer ‘{name}’? लेयर ‘{name}’ हटाएँ? + Delete {count} layers? {names} {count} लेयर हटाएँ? {names} + Delete layer? लेयर हटाएँ? + Don't show again दोबारा न दिखाएँ + The document is closed दस्तावेज़ बंद है + The document or selected layers changed. Please request deletion again. दस्तावेज़ या चुनी हुई लेयर बदल गई हैं। कृपया हटाने का अनुरोध फिर से करें। + Missing delete command हटाने का कमांड नहीं मिला + Invalid delete command हटाने का कमांड अमान्य है + Layer deleted, but confirmation preference could not be saved: {error} लेयर हटा दी गई, लेकिन पुष्टि की प्राथमिकता सहेजी नहीं जा सकी: {error} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c066f42711..cf66b32534 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1114,6 +1114,8 @@ Height: Magasság: Hide Auto Slices Automatikus szeletek elrejtése History Brush Előzményecset + Paints from the History panel's source state Az Előzmények panel forrásállapotából fest + Sets the source for the history brush Beállítja az előzményecset forrását Horizontal scale Vízszintes méretarány Horizontal type Vízszintes szöveg Hue Jitter Színárnyalat ingadozása @@ -1218,6 +1220,8 @@ Override Pressure Nyomás felülbírálása Override Rotation Elforgatás felülbírálása Override Tilt Dőlés felülbírálása + Override Tilt X X dőlés felülbírálása + Override Tilt Y Y dőlés felülbírálása Paints from the document's opening state A dokumentum megnyitáskori állapotából fest Paper Papír Paragraph Bekezdés @@ -1304,6 +1308,7 @@ Set painting symmetry options Festési szimmetria beállításai Set the overlay options for the Crop tool A Körbevágás eszköz fedvénybeállításai Set the text color Szövegszín beállítása + Create warped text Torzított szöveg létrehozása Settings: Beállítások: Shape Dynamics Alakdinamika Sharpen Tool Élesítő @@ -1614,6 +1619,7 @@ fontWeight Thin Hajszálvékony Overlay Átfedés Overlay Color Átfedés színe Overlay Opacity Átfedés átlátszatlansága + Overprint Felülnyomás Paint over hair or fur on the image; Alt paints the other mode Fesd át a hajat vagy a szőrzetet a képen; Alt lenyomásával a másik módban festesz Pass Through Áteresztés Pattern Overlay Mintázatfedés @@ -2074,7 +2080,7 @@ navigator Zoom Nagyítás mértéke Distortion Torzítás Dither Árnyalatkeverés Double Line Kettős vonal - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Húzással kényszert adhatsz a képhez; Shift: vízszintes/függőleges; jobb kattintás: törlés. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Húzással kényszert adhatsz a képhez; Shift: vízszintes/függőleges; jobb kattintás egy kényszeren: módosítás vagy törlés. Dry Brush Száraz ecset Dust & Scratches Por és karcolások Dynamic color sliders Dinamikus színcsúszkák @@ -2989,6 +2995,11 @@ cameraRaw Yellow Sárga Table Táblázat Tabloid Tabloid Targa Options Targa-beállítások + JPEG Options JPEG-beállítások + Image Options Képbeállítások + Format Options Formátumbeállítások + Baseline ("Standard") Alapvonalas („Szabványos”) + Baseline Optimized Optimalizált alapvonalas Target: Cél: Teal Orange Kékeszöld-narancs The document does not have an embedded colour profile. Working space: {working}. A dokumentumnak nincs beágyazott színprofilja. Munkaszíntér: {working}. @@ -3145,6 +3156,8 @@ cameraRaw Yellow Sárga Convert to Profile Átalakítás profilra Convert to srgb Átalakítás sRGB-re Copyright Status Szerzői jogi állapot + Copyright Notice Szerzői jogi értesítés + Document Title Dokumentum címe Corner radius Sarokkerekítés sugara Correct C A Kromatikus aberráció javítása Correct Distortion Torzítás javítása @@ -3198,6 +3211,7 @@ cameraRaw Yellow Sárga Drop an image or PSD anywhere to open it. Húzz ide bárhová egy képet vagy PSD-fájlt a megnyitásához. Duplicate Layer Comp Rétegkompozíció kettőzése Duplicate Path Útvonal kettőzése + Rename Path… Útvonal átnevezése… Duplication Kettőzés East asian Kelet-ázsiai Edge Extension Szélek kiterjesztése @@ -3476,6 +3490,7 @@ cameraRaw Yellow Sárga Random Véletlenszerű Rasterize svg groups deeper than SVG-csoportok raszterizálása ennél mélyebb szinten Recent file count Legutóbbi fájlok száma + LUT folder LUT-mappa Recent files Legutóbbi fájlok Recent Files Legutóbbi fájlok Recent fonts Legutóbbi betűtípusok @@ -3504,6 +3519,8 @@ cameraRaw Yellow Sárga Restore Előző méret Retry GPU GPU újrapróbálása Retry GPU requires a restart. Save your work first. A GPU újrapróbálásához újraindítás kell. Előbb mentsd a munkádat. + Retry GPU tries the graphics processor again now. A GPU újrapróbálása most újra megpróbálja a grafikus processzort. + GPU acceleration resumed. A GPU-gyorsítás újra működik. Rgb RGB Ridges Hullámgerincek Right Jobbra @@ -3874,6 +3891,8 @@ cameraRaw Yellow Sárga New Layer from Brush Új réteg ecsetből New Path Új útvonal New Snapshot Új pillanatkép + New Snapshot… Új pillanatkép… + History Options… Előzménybeállítások… New Video Group Új videócsoport Newspaper Újságpapír No Layer Comps Nincs rétegkompozíció @@ -4022,6 +4041,12 @@ cameraRaw Yellow Sárga The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzd újra a maszkot a Kukába. Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív + Load Contours… Kontúrok betöltése… + Replace Contours… Kontúrok cseréje… + Save Contours… Kontúrok mentése… + Delete Contour Kontúr törlése + Loaded contours from {file} Kontúrok betöltve: {file} + Saved contours to {file} Kontúrok mentve: {file} # Edit › Content-Aware Fill workspace Excluded Area Kizárt terület Fill Settings Kitöltési beállítások @@ -4048,6 +4073,9 @@ cameraRaw Yellow Sárga # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Műveletek betöltése… Save Actions… Műveletek mentése… + New Action… Új művelet… + Start Recording Felvétel indítása + Stop Recording Felvétel leállítása {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} művelet betöltve. Forrás: {file} @@ -4068,6 +4096,7 @@ cameraRaw Yellow Sárga @plural {n} action|{n} actions {n} művelet Object Finder Objektumkereső No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Az aktív rétegnek itt nincsenek képpontjai – kapcsold be a Mintavétel minden rétegből beállítást, vagy válassz képpontokat tartalmazó réteget Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás Gradient Preset Options Színátmenet-előbeállítás beállításai Import .GRD .GRD importálása @@ -4088,12 +4117,23 @@ cameraRaw Yellow Sárga set in Print Settings… a nyomtatási beállításokban adható meg… ICO is limited to 256 × 256 pixels Az ICO legfeljebb 256 × 256 képpont lehet +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maszkbeállítások… + Layer Mask Display Options… Rétegmaszk megjelenítési beállításai… + # Channel Options and New Spot Channel (#3761) Channel Color Csatorna színe Color Indicates: A szín jelzése: Ink Characteristics Festék jellemzői Solidity: Fedőképesség: Redrawing Újrarajzolás +# Bevel & Emboss shading controls. + Highlight Mode Csúcsfény módja + Highlight Color Csúcsfény színe + Highlight Opacity Csúcsfény fedettsége + Shadow Mode Árnyék módja + Shadow Color Árnyék színe + Shadow Opacity Árnyék fedettsége # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) A beágyazott profil használata ({profile}) Convert to the working space ({profile}) Átalakítás a munkaszíntérbe ({profile}) @@ -4110,10 +4150,14 @@ cameraRaw Yellow Sárga Chisel Hard Kemény vésés Chisel Soft Lágy vésés Open color-less EXR as ACEScg Színinformáció nélküli EXR megnyitása ACEScg-ként + Do not append "copy" to filename when saving a copy Ne fűzze a „copy” szót a fájlnévhez másolat mentésekor # View › Guides › right-click a guide Edit Selected Guides Kijelölt segédvonalak szerkesztése Clear Selected Guides Kijelölt segédvonalak törlése + Bicubic Automatic Automatikus bikubikus + Bicubic Smoother Simább bikubikus + Bicubic Sharper Élesebb bikubikus Pasting from other apps is unavailable A beillesztés más alkalmazásokból nem érhető el PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. A PhotoCraft ebben a munkamenetben nem fér hozzá a rendszer vágólapjához. Waylanden a Flatpak homokozó elrejti azt a protokollt, amelyen keresztül más alkalmazások képei olvashatók. To paste from other apps, start PhotoCraft under XWayland: `{command}` Ha más alkalmazásokból szeretne beilleszteni, indítsa a PhotoCraftot XWayland alatt: `{command}` @@ -4133,6 +4177,43 @@ cameraRaw Yellow Sárga set name készlet neve {set} (modified) {set} (módosítva) Show file name in title Fájlnév megjelenítése a címben + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50%-os küszöb + Pattern Dither Mintázatos keverés + Diffusion Dither Diffúziós keverés + Halftone Screen Féltónusrács + Custom Pattern Egyéni mintázat + Frequency: Gyakoriság: + Lines/Inch Vonal/hüvelyk + Lines/Centimeter Vonal/centiméter + Ellipse Ellipszis + +# Layers panel options and Smart Filter editors + Add “copy” to Copied Layers and Groups „másolat” hozzáadása a másolt rétegekhez és csoportokhoz + Animation Options Animációs beállítások + Entire Document Teljes dokumentum + Expand New Effects Új effektusok kibontása + Filter Options Szűrési beállítások + Layer Bounds Réteghatárok + Layers Panel Options Rétegek panel beállításai + New Artboard… Új rajztábla… + New Group… Új csoport… + Prevent Auto-Nesting Automatikus beágyazás tiltása + Preview on canvas when reordering layers Előnézet a vásznon a rétegek átrendezésekor + Show Layers Rétegek megjelenítése + Show layer mask badges Rétegmaszk-jelvények megjelenítése + Thumbnail Contents Bélyegkép tartalma + Thumbnail Size Bélyegképméret + Use Default Masks on Fill Layers Alapértelmezett maszkok kitöltőrétegeken + + Blending Options ({filter}) Keverési beállítások ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) A réteg effektusainak és intelligens szűrőinek elrejtése ({key}+kattintás: minden réteg) + Opacity must be between 0% and 100%. Az átlátszatlanságnak 0% és 100% között kell lennie. + Dissolve is approximated: it blends without Photoshop's dither. A Szétszórás csak közelítés: a Photoshop raszterezése nélkül keveredik. + Show the layer's effects and Smart Filters ({key}-click: all layers) A réteg effektusainak és intelligens szűrőinek megjelenítése ({key}+kattintás: minden réteg) + The document or Smart Filter changed. Reopen its dialog. A dokumentum vagy az intelligens szűrő megváltozott. Nyisd meg újra a párbeszédablakát. + Wait for the current job to finish. Várd meg, amíg az aktuális feladat befejeződik. Accept and Go to Conflict Elfogadás és ugrás az ütközéshez Changes undone. Változások visszavonva. Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle. @@ -4150,15 +4231,33 @@ cameraRaw Yellow Sárga GPU Memory Full GPU-memória megtelt The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. A rétegeknek {needed} GPU-memória kell, és {budget} foglalhatnak, ezért a teljes dokumentumot újrarajzoló szerkesztések a CPU-n futnak. Kattints a GPU-memória beállításához. GPU memory budget: {budget} GPU-memóriakeret: {budget} + Graphics memory: {size} Grafikus memória: {size} This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) + {path}: the path is not valid Unicode; save under another name. {path}: az elérési út nem érvényes Unicode-szöveg. Mentsd más néven. {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. Decrease Brush Size Ecsetméret csökkentése Increase Brush Size Ecsetméret növelése Decrease Brush Hardness Ecsetkeménység csökkentése Increase Brush Hardness Ecsetkeménység növelése + Demosaic Demozaikolás + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hogyan tölti ki a program a kamera színszűrő-mozaikját. A fájl megnyitásakor érvényesül; az előnézet a megnyitáskor használt módszert mutatja. + AHD (adaptive homogeneity) AHD (adaptív homogenitás) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: a legtisztább élek és a legkevesebb színes szegély, a leglassabb. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradienskorrigált interpoláció: éles és gyors, apró részleteken némi színes szegéllyel. + The fastest and the softest, with colour fringing on fine detail. A leggyorsabb és a leglágyabb, apró részleteken színes szegéllyel. + “{name}” already exists. Replace it? „{name}” már létezik. Lecseréli? + The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”. + Move Up Mozgatás felfelé + Move Down Mozgatás lefelé + Previous Brush Előző ecset + Next Brush Következő ecset + First Brush Első ecset + Last Brush Utolsó ecset + Preview size Preview size Memory Usage Memóriahasználat Per-document RAM limit (MB) Dokumentumonkénti RAM-korlát (MB) Installed RAM: {ram} MB; this limit: {percent}% Telepített RAM: {ram} MB; ez a korlát: {percent}% @@ -4173,5 +4272,28 @@ cameraRaw Yellow Sárga DDS Options DDS-beállítások Generate Mipmaps Mipmapek létrehozása +# Color Table (.act) dialog + There is no color table to save. Nincs menthető színtábla. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} szín mentve ide: {file} + # Graphics Adapter preference (#1885) GPU adapter GPU-adapter +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Előbb hagyd jóvá vagy vond vissza a művelet lépésének nyitott párbeszédablakát + Pro Medium Gray Pro középszürke + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro világosszürke + Pro Light Pro világos + Resize Windows to Fit Ablakok átméretezése a képhez + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatikus bekezdésirány + Left-to-right paragraph direction Balról jobbra irányuló bekezdés + Right-to-left paragraph direction Jobbról balra irányuló bekezdés + Not rendered: this adjustment ({key}) is kept as imported. Nem jelenik meg: ez a korrekció ({key}) importált állapotában marad. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9e0012bd64..6198dd099b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -12,6 +12,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opsi Mask… + Layer Mask Display Options… Opsi Tampilan Mask Layer… + # Channel Options and New Spot Channel (#3761) Channel Color Warna Saluran Color Indicates: Warna Menunjukkan: @@ -795,6 +799,22 @@ Split Warp Vertically Bagi Warp Vertikal Statistics… Statistik… Refine Strokes Goresan penyempurnaan +# Counted status and panel messages +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} kuas diimpor dari {group} +@plural Played {n} step|Played {n} steps {n} langkah diputar +@plural {n} step|{n} steps {n} langkah +@plural Saved for Web: {n} file|Saved for Web: {n} files Disimpan untuk Web: {n} file +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} Smart Object tertaut yang berubah di disk telah diperbarui +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} layer yang direkam oleh layer comp ini telah dihapus. Perbarui layer comp, atau hapus peringatannya. +@plural {n} plane|{n} planes {n} bidang +@plural {n} preset|{n} presets {n} preset +@plural {n} sample point removed|{n} sample points removed {n} titik sampel dihapus +@plural {n} slice|{n} slices {n} slice +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} peringatan preset kuas lainnya) +@plural {what} with {n} warning|{what} with {n} warnings {what} dengan {n} peringatan + @plural {n} item|{n} items {n} item Auto Otomatis (print to the printer) (cetak ke printer) @@ -1074,7 +1094,7 @@ Done Selesai Dots Titik Drag a rectangle around the object Seret persegi panjang di sekeliling objek - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Seret pada gambar untuk menambah batasan; Shift untuk horizontal/vertikal; klik kanan untuk menghapus. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Seret pada gambar untuk menambah batasan; Shift untuk horizontal/vertikal; klik kanan pada batasan untuk mengubah atau menghapusnya. Drag to move the active shape's path or the Work Path Seret untuk memindah path bentuk aktif atau Work Path Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: pilih titik jangkar · Seret: pindahkan titik, gagang, atau segmen · {shift}-klik: tambah · {alt}-klik: seluruh subpath Draw with the Pen tool (P) or make a work path from a selection. Gambar dengan alat Pen (P) atau buat work path dari seleksi. @@ -1310,6 +1330,8 @@ Override Pressure Timpa Tekanan Override Rotation Timpa Rotasi Override Tilt Timpa Kemiringan + Override Tilt X Timpa Kemiringan X + Override Tilt Y Timpa Kemiringan Y PRESET DETAILS DETAIL PRESET PSD PSD Paint Overlay Overlay Cat @@ -1462,6 +1484,7 @@ Set shape fill type Atur jenis isian bentuk Set shape stroke type Atur jenis stroke bentuk Set the text color Atur warna teks + Create warped text Buat teks warp Shadows Bayangan Shape: Bentuk: Sharpening Penajaman @@ -1958,6 +1981,7 @@ Quick export format Format ekspor cepat Quick export location Lokasi ekspor cepat Recent file count Jumlah berkas terbaru + LUT folder Folder LUT Recent files Berkas terbaru Recent fonts Font terbaru Recover on launch Pulihkan saat dibuka @@ -2362,6 +2386,7 @@ Clipping Path Clipping Path New Path Path Baru Duplicate Path Duplikat Path + Rename Path… Ganti Nama Path… Delete Path Hapus Path Auto-Hide Sembunyi Otomatis W W @@ -2427,6 +2452,8 @@ Keywords Kata Kunci Copyright Info URL URL Info Hak Cipta Copyright Status Status Hak Cipta + Copyright Notice Pemberitahuan Hak Cipta + Document Title Judul Dokumen Copyrighted Berhak Cipta Public Domain Domain Publik Unknown Tidak Diketahui @@ -2645,6 +2672,7 @@ 32 bit (float) 32 bit (titik mengambang) Floating point Titik mengambang Use Global Light Gunakan Cahaya Global + Overprint Cetak Tindih Native file drag-and-drop is unavailable Seret dan lepas berkas native tidak tersedia Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Seret dan lepas berkas native belum didukung di Wayland. Gunakan Berkas › Buka, atau salin gambar di pengelola berkas lalu tempel dengan {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Untuk menjatuhkan berkas, jalankan PhotoCraft di XWayland: `{command}` @@ -2690,6 +2718,8 @@ cameraRaw Blending Pembauran GPU acceleration could not continue. Your documents are unchanged. Akselerasi GPU tidak dapat dilanjutkan. Dokumen Anda tidak berubah. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft menggunakan kompositor gambar CPU. Jendela mungkin masih menggunakan adaptor grafis Anda. Retry GPU requires a restart. Save your work first. Mencoba GPU lagi memerlukan mulai ulang. Simpan pekerjaan Anda terlebih dahulu. + Retry GPU tries the graphics processor again now. Coba GPU Lagi mencoba prosesor grafis lagi sekarang. + GPU acceleration resumed. Akselerasi GPU dilanjutkan. Keep Using CPU Tetap Gunakan CPU Retry GPU Coba GPU Lagi GPU Backend Backend GPU @@ -2722,6 +2752,11 @@ cameraRaw Blending Pembauran Content-Aware Move Tool Alat Content-Aware Move TIFF Options Opsi TIFF Targa Options Opsi Targa + JPEG Options Opsi JPEG + Image Options Opsi Gambar + Format Options Opsi Format + Baseline ("Standard") Baseline ("Standar") + Baseline Optimized Baseline Dioptimalkan 24 bits/pixel 24 Bit/Piksel 32 bits/pixel 32 Bit/Piksel Including layers will increase file size. Menyertakan layer akan memperbesar ukuran file. @@ -3061,6 +3096,33 @@ cameraData Lens Lensa Output To Keluaran Ke Duplicate Layer Duplikat Layer Contract Ciutkan + + Animation Options Opsi Animasi + Entire Document Seluruh Dokumen + Filter Options Opsi Filter + Layer Bounds Batas Lapisan + Layers Panel Options Opsi Panel Lapisan + Lock Layers Kunci Lapisan + New Artboard… Artboard Baru… + New Group… Grup Baru… + Prevent Auto-Nesting Cegah Pengelompokan Otomatis + Show Layers Tampilkan Lapisan + Thumbnail Contents Isi Gambar Mini + Thumbnail Size Ukuran Gambar Mini + Use Default Masks on Fill Layers Gunakan Masker Default pada Lapisan Isian + Expand New Effects Perluas Efek Baru + Add “copy” to Copied Layers and Groups Tambahkan “salinan” ke Lapisan dan Grup yang Disalin + Preview on canvas when reordering layers Pratinjau pada kanvas saat mengurutkan ulang lapisan + Show layer mask badges Tampilkan Lencana Masker Lapisan + +# Smart Filter editors + Blending Options ({filter}) Opsi Pencampuran ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Sembunyikan efek lapisan dan Filter Cerdas (klik-{key}: semua lapisan) + Show the layer's effects and Smart Filters ({key}-click: all layers) Tampilkan efek lapisan dan Filter Cerdas (klik-{key}: semua lapisan) + Wait for the current job to finish. Tunggu hingga tugas saat ini selesai. + The document or Smart Filter changed. Reopen its dialog. Dokumen atau Filter Cerdas berubah. Buka kembali dialognya. + Opacity must be between 0% and 100%. Opasitas harus antara 0% dan 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dissolve hanya didekati: pencampuran berjalan tanpa dither Photoshop. Loading fonts… Memuat font… Response curve Kurva respons Tool Alat @@ -3106,6 +3168,10 @@ curves Smooth Haluskan Click to place · Alt-click or drag off-canvas to remove Klik untuk menempatkan · Alt-klik atau seret ke luar kanvas untuk menghapus No document open Tidak ada dokumen yang terbuka {n} of {max} sample points {n} dari {max} titik sampel + +# History panel menu (#2966) + New Snapshot… Snapshot Baru… + History Options… Opsi Riwayat… # Print dialog (#2697) Emulsion Down Emulsi di Bawah Functions Fungsi @@ -3125,9 +3191,16 @@ curves Smooth Haluskan Light Gray Abu-abu Muda Object Finder Pencari Objek No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Layer aktif tidak memiliki piksel di sini — aktifkan Sampel Semua Layer atau pilih layer yang berisi piksel Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Load Contours… Muat kontur… + Replace Contours… Ganti kontur… + Save Contours… Simpan kontur… + Delete Contour Hapus kontur + Loaded contours from {file} Kontur dimuat dari {file} + Saved contours to {file} Kontur disimpan ke {file} Show home recent files Tampilkan file terbaru di layar beranda Clear Brush Controls Hapus Kontrol Kuas Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci @@ -3136,6 +3209,9 @@ curves Smooth Haluskan # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Muat Tindakan… Save Actions… Simpan Tindakan… + New Action… Tindakan Baru… + Start Recording Mulai Rekaman + Stop Recording Hentikan Rekaman {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} tindakan dimuat dari {file} @@ -3168,6 +3244,16 @@ curves Smooth Haluskan Sent to {printer} Dikirim ke {printer} set in Print Settings… diatur di Pengaturan Cetak… ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel +# History panel › History Brush source (#2966) + Paints from the History panel's source state Melukis dari status sumber panel Riwayat + Sets the source for the history brush Menetapkan sumber untuk kuas riwayat +# Bevel & Emboss shading controls. + Highlight Mode Mode Sorotan + Highlight Color Warna Sorotan + Highlight Opacity Opasitas Sorotan + Shadow Mode Mode Bayangan + Shadow Color Warna Bayangan + Shadow Opacity Opasitas Bayangan # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Pakai profil tertanam ({profile}) Convert to the working space ({profile}) Konversi ke working space ({profile}) @@ -3184,10 +3270,14 @@ curves Smooth Haluskan Chisel Hard Pahat Keras Chisel Soft Pahat Lembut Open color-less EXR as ACEScg Buka EXR tanpa informasi warna sebagai ACEScg + Do not append "copy" to filename when saving a copy Jangan tambahkan "copy" ke nama file saat menyimpan salinan # View › Guides › right-click a guide Edit Selected Guides Edit Panduan Terpilih Clear Selected Guides Hapus Panduan Terpilih + Bicubic Automatic Bicubic otomatis + Bicubic Smoother Bicubic lebih halus + Bicubic Sharper Bicubic lebih tajam Pasting from other apps is unavailable Menempel dari aplikasi lain tidak tersedia PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft tidak dapat mengakses papan klip sistem dalam sesi ini. Di Wayland, sandbox Flatpak menyembunyikan protokol yang digunakan untuk membaca gambar dari aplikasi lain. To paste from other apps, start PhotoCraft under XWayland: `{command}` Untuk menempel dari aplikasi lain, jalankan PhotoCraft di bawah XWayland: `{command}` @@ -3208,6 +3298,18 @@ curves Smooth Haluskan {set} (modified) {set} (diubah) Random Acak Show file name in title Tampilkan nama file di judul + +# Image › Mode › Bitmap (#5559) + 50% Threshold Threshold 50% + Pattern Dither Dither Pola + Diffusion Dither Dither Difusi + Halftone Screen Layar Halftone + Custom Pattern Pola Kustom + Frequency: Frekuensi: + Lines/Inch Garis/Inci + Lines/Centimeter Garis/Sentimeter + Ellipse Elips + Cross Silang Accept and Go to Conflict Terima dan Buka Konflik Changes undone. Perubahan dibatalkan. Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan. @@ -3225,9 +3327,11 @@ curves Smooth Haluskan GPU Memory Full Memori GPU Penuh The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Layer membutuhkan {needed} memori GPU dan boleh memakai {budget}, sehingga suntingan yang menggambar ulang seluruh dokumen melakukannya di CPU. Klik untuk mengatur Memori GPU. GPU memory budget: {budget} Anggaran memori GPU: {budget} + Graphics memory: {size} Memori grafis: {size} This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) + {path}: the path is not valid Unicode; save under another name. {path}: jalur bukan Unicode yang valid; simpan dengan nama lain. Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. @@ -3235,6 +3339,22 @@ curves Smooth Haluskan Increase Brush Size Perbesar ukuran kuas Decrease Brush Hardness Kurangi kekerasan kuas Increase Brush Hardness Tambah kekerasan kuas + Demosaic Demosaik + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cara mosaik filter warna kamera diisi. Diterapkan saat berkas dibuka; pratinjau menampilkan metode yang dipakai saat dibuka. + AHD (adaptive homogeneity) AHD (homogenitas adaptif) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: tepi paling bersih dan pinggiran warna paling sedikit, paling lambat. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolasi terkoreksi gradien: tajam dan cepat, dengan sedikit pinggiran warna pada detail halus. + The fastest and the softest, with colour fringing on fine detail. Paling cepat dan paling lembut, dengan pinggiran warna pada detail halus. + “{name}” already exists. Replace it? “{name}” sudah ada. Ganti? + The name in the save dialog was “{name}”. Nama di dialog simpan adalah “{name}”. + Move Up Pindah ke atas + Move Down Pindah ke bawah + Previous Brush Kuas sebelumnya + Next Brush Kuas berikutnya + First Brush Kuas pertama + Last Brush Kuas terakhir + Preview size Preview size Memory Usage Penggunaan memori Per-document RAM limit (MB) Batas RAM per dokumen (MB) Installed RAM: {ram} MB; this limit: {percent}% RAM terpasang: {ram} MB; batas ini: {percent}% @@ -3250,5 +3370,28 @@ curves Smooth Haluskan Compression Kompresi Generate Mipmaps Buat mipmap +# Color Table (.act) dialog + There is no color table to save. Tidak ada tabel warna untuk disimpan. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} warna disimpan ke {file} + # Graphics Adapter preference (#1885) GPU adapter Adaptor GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Konfirmasi atau batalkan dulu dialog langkah tindakan yang terbuka + Pro Medium Gray Pro Abu-abu Sedang + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro Abu-abu Terang + Pro Light Pro Terang + Resize Windows to Fit Ubah ukuran jendela agar pas + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Arah paragraf otomatis + Left-to-right paragraph direction Arah paragraf kiri ke kanan + Right-to-left paragraph direction Arah paragraf kanan ke kiri + Not rendered: this adjustment ({key}) is kept as imported. Tidak dirender: penyesuaian ini ({key}) disimpan seperti saat diimpor. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fe8d4cf8dd..a7b7c85381 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -12,6 +12,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing … of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opzioni maschera… + Layer Mask Display Options… Opzioni di visualizzazione maschera di livello… + # Channel Options and New Spot Channel (#3761) Channel Color Colore canale Color Indicates: Il colore indica: @@ -327,6 +331,7 @@ Blending Options… Opzioni di fusione… Bevel & Emboss… Smusso e rilievo… Use Global Light Usa luce globale + Overprint Sovrastampa Inner Shadow… Ombra interna… Inner Glow… Bagliore interno… Satin… Raso… @@ -1089,7 +1094,7 @@ Done Fatto Dots Punti Drag a rectangle around the object Trascina un rettangolo attorno all'oggetto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro per eliminare. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro su un vincolo per modificarlo o eliminarlo. Drag to move the active shape's path or the Work Path Trascina per spostare il tracciato forma attiva o il Tracciato di lavoro Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleziona punto · Trascina: sposta punto, maniglia o segmento · {shift}-clic: aggiungi · {alt}-clic: intero sottotracciato Draw with the Pen tool (P) or make a work path from a selection. Disegna con Penna (P) o crea un tracciato di lavoro da una selezione. @@ -1331,6 +1336,8 @@ Override Pressure Sovrascrivi pressione Override Rotation Sovrascrivi rotazione Override Tilt Sovrascrivi inclinazione + Override Tilt X Sovrascrivi inclinazione X + Override Tilt Y Sovrascrivi inclinazione Y PRESET DETAILS DETTAGLI PREDEFINITO PSD PSD Paint Overlay Sovrapposizione pittura @@ -1483,6 +1490,7 @@ Set shape fill type Imposta tipo riempimento forma Set shape stroke type Imposta tipo traccia forma Set the text color Imposta colore testo + Create warped text Crea testo deformato Shadows Ombre Shape: Forma: Sharpening Contrasto @@ -1989,6 +1997,7 @@ Quick export format Formato esportazione rapida Quick export location Posizione esportazione rapida Recent file count Conteggio file recenti + LUT folder Cartella LUT Recent files File recenti Recent fonts Font recenti Recover on launch Recupera all'avvio @@ -1998,7 +2007,7 @@ Same folder Stessa cartella Save in background Salva in background Scratch Disks Dischi virtuali - Screen Schermo + Screen Scherma Screen resolution Risoluzione schermo Scrubby slider acceleration Accelerazione slider scrubby Sessions only Solo sessioni @@ -2324,6 +2333,8 @@ GPU acceleration could not continue. Your documents are unchanged. Accelerazione GPU non ha potuto continuare. I documenti sono invariati. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft sta componendo immagini su CPU. La finestra può ancora usare adattatore grafico. Retry GPU requires a restart. Save your work first. Riprovare GPU richiede riavvio. Salva prima il lavoro. + Retry GPU tries the graphics processor again now. Riprova GPU riprova subito il processore grafico. + GPU acceleration resumed. Accelerazione GPU ripresa. Keep Using CPU Continua su CPU Retry GPU Riprova GPU Advanced Avanzato @@ -2378,6 +2389,11 @@ cameraRaw Amount Quantità Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Trascina o fai clic sui punti del poligono · tieni premuto Alt durante il lazo per segmenti dritti · {add} aggiunge · {sub} prima di disegnare sottrae TIFF Options Opzioni TIFF Targa Options Opzioni Targa + JPEG Options Opzioni JPEG + Image Options Opzioni immagine + Format Options Opzioni formato + Baseline ("Standard") Linea di base ("Standard") + Baseline Optimized Linea di base ottimizzata 24 bits/pixel 24 bit/pixel 32 bits/pixel 32 bit/pixel Including layers will increase file size. Includere i livelli aumenterà le dimensioni del file. @@ -2726,6 +2742,32 @@ cameraData Lens Obiettivo Mirror Rifletti Output Settings Impostazioni di output Output To Output in + + Animation Options Opzioni animazione + Entire Document Documento intero + Filter Options Opzioni filtro + Layer Bounds Limiti livello + Layers Panel Options Opzioni pannello Livelli + New Artboard… Nuova tavola da disegno… + New Group… Nuovo gruppo… + Prevent Auto-Nesting Impedisci annidamento automatico + Show Layers Mostra livelli + Thumbnail Contents Contenuto miniature + Thumbnail Size Dimensione miniature + Use Default Masks on Fill Layers Usa maschere predefinite sui livelli di riempimento + Expand New Effects Espandi nuovi effetti + Add “copy” to Copied Layers and Groups Aggiungi «copia» ai livelli e ai gruppi duplicati + Preview on canvas when reordering layers Anteprima sulla tela durante il riordino dei livelli + Show layer mask badges Mostra distintivi delle maschere di livello + +# Smart Filter editors + Blending Options ({filter}) Opzioni di fusione ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Nascondi effetti del livello e filtri avanzati ({key}-clic: tutti i livelli) + Show the layer's effects and Smart Filters ({key}-click: all layers) Mostra effetti del livello e filtri avanzati ({key}-clic: tutti i livelli) + Wait for the current job to finish. Attendi il completamento dell'attività corrente. + The document or Smart Filter changed. Reopen its dialog. Il documento o il filtro avanzato è cambiato. Riapri la sua finestra di dialogo. + Opacity must be between 0% and 100%. L'opacità deve essere compresa tra 0% e 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dissolvi è approssimato: la fusione avviene senza il dither di Photoshop. Loading fonts… Caricamento font… Response curve Curva di risposta Tool Strumento @@ -2773,6 +2815,10 @@ curves Smooth Leviga Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dal quadro per rimuovere No document open Nessun documento aperto {n} of {max} sample points {n} di {max} punti di campionamento + +# History panel menu (#2966) + New Snapshot… Nuova istantanea… + History Options… Opzioni cronologia… # Print dialog (#2697) Emulsion Down Emulsione sotto Functions Funzioni @@ -2948,6 +2994,7 @@ curves Smooth Leviga Clipping Path Tracciato di ritaglio New Path Nuovo tracciato Duplicate Path Duplica tracciato + Rename Path… Rinomina tracciato… Auto-Hide Nascondi automaticamente X X Y Y @@ -3002,6 +3049,8 @@ curves Smooth Leviga Keywords Parole chiave Copyright Info URL URL info copyright Copyright Status Stato copyright + Copyright Notice Avviso sul copyright + Document Title Titolo documento Copyrighted Protetto da copyright Public Domain Pubblico dominio Unknown Sconosciuto @@ -4097,9 +4146,16 @@ navigator Zoom Zoom {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. Object Finder Ricerca oggetti No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Il livello attivo non ha pixel qui: attiva Campiona tutti i livelli o seleziona un livello con pixel Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Load Contours… Carica profili… + Replace Contours… Sostituisci profili… + Save Contours… Salva profili… + Delete Contour Elimina profilo + Loaded contours from {file} Profili caricati da {file} + Saved contours to {file} Profili salvati in {file} Show home recent files Mostra i file recenti nella schermata iniziale Clear Brush Controls Cancella controlli pennello Reset All Locked Settings Ripristina tutte le impostazioni bloccate @@ -4108,6 +4164,9 @@ navigator Zoom Zoom # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carica azioni… Save Actions… Salva azioni… + New Action… Nuova azione… + Start Recording Avvia registrazione + Stop Recording Interrompi registrazione {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Caricata {n} azione da {file}|Caricate {n} azioni da {file} @@ -4139,6 +4198,16 @@ navigator Zoom Zoom Sent to {printer} Inviato a {printer} set in Print Settings… impostato nelle impostazioni di stampa… ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel +# History panel › History Brush source (#2966) + Paints from the History panel's source state Dipinge dallo stato di origine del pannello Cronologia + Sets the source for the history brush Imposta l'origine del pennello storia +# Bevel & Emboss shading controls. + Highlight Mode Metodo luci + Highlight Color Colore luci + Highlight Opacity Opacità luci + Shadow Mode Metodo ombre + Shadow Color Colore ombre + Shadow Opacity Opacità ombre # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Usa profilo incorporato ({profile}) Convert to the working space ({profile}) Converti in spazio di lavoro ({profile}) @@ -4154,6 +4223,7 @@ navigator Zoom Zoom Chisel Hard Cesello duro Chisel Soft Cesello morbido Open color-less EXR as ACEScg Apri gli EXR senza spazio colore come ACEScg + Do not append "copy" to filename when saving a copy Non aggiungere "copy" al nome del file quando si salva una copia # Color management, Liquify, glyph group, warp and Camera Raw labels the Serbian, Vietnamese and Japanese catalogs added Adobe-rgb-compat Adobe RGB (compatibile) @@ -4256,6 +4326,11 @@ navigator Zoom Zoom Increase Brush Size Aumenta dimensione pennello Decrease Brush Hardness Riduci durezza pennello Increase Brush Hardness Aumenta durezza pennello + Previous Brush Pennello precedente + Next Brush Pennello successivo + First Brush Primo pennello + Last Brush Ultimo pennello + Preview size Preview size Space Barra spaziatrice Hand Tool (hold) Strumento Mano (tieni premuto) Zoom In (hold, drag to scrub) Ingrandisci (tieni premuto, trascina per lo zoom continuo) @@ -4316,6 +4391,9 @@ shape Appearance Aspetto # View › Guides › right-click a guide Edit Selected Guides Modifica guide selezionate Clear Selected Guides Cancella guide selezionate + Bicubic Automatic Bicubica automatica + Bicubic Smoother Bicubica più morbida + Bicubic Sharper Bicubica più nitida Pasting from other apps is unavailable Incollare da altre app non è disponibile PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft non può accedere agli appunti di sistema in questa sessione. Su Wayland, la sandbox di Flatpak nasconde il protocollo con cui vengono lette le immagini delle altre app. To paste from other apps, start PhotoCraft under XWayland: `{command}` Per incollare da altre app, avvia PhotoCraft in XWayland: `{command}` @@ -4335,6 +4413,17 @@ shape Appearance Aspetto set name nome del set {set} (modified) {set} (modificato) Show file name in title Mostra nome file nel titolo + +# Image › Mode › Bitmap (#5559) + 50% Threshold Soglia 50% + Pattern Dither Dithering pattern + Diffusion Dither Dithering diffusione + Halftone Screen Retino mezzetinte + Custom Pattern Pattern personalizzato + Frequency: Frequenza: + Lines/Inch Linee/Pollice + Lines/Centimeter Linee/Centimetro + Ellipse Ellisse Accept and Go to Conflict Accetta e vai al conflitto Changes undone. Modifiche annullate. Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza. @@ -4352,11 +4441,24 @@ shape Appearance Aspetto GPU Memory Full Memoria GPU piena The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. I livelli richiedono {needed} di memoria GPU e possono occuparne {budget}, quindi le modifiche che ridisegnano tutto il documento lo fanno sulla CPU. Fai clic per impostare Memoria GPU. GPU memory budget: {budget} Budget di memoria GPU: {budget} + Graphics memory: {size} Memoria grafica: {size} This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) + {path}: the path is not valid Unicode; save under another name. {path}: il percorso non è Unicode valido; salva con un altro nome. {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + Demosaic Demosaicizzazione + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Come viene completato il mosaico del filtro colore della fotocamera. Si applica all'apertura del file; l'anteprima mostra il metodo con cui è stato aperto. + AHD (adaptive homogeneity) AHD (omogeneità adattiva) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: i bordi più puliti e le minori frange di colore, il più lento. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolazione con correzione del gradiente: nitida e veloce, con qualche frangia di colore nei dettagli fini. + The fastest and the softest, with colour fringing on fine detail. Il più veloce e il più morbido, con frange di colore nei dettagli fini. + “{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo? + The name in the save dialog was “{name}”. Il nome nella finestra di salvataggio era “{name}”. + Move Up Sposta su + Move Down Sposta giù Memory Usage Utilizzo memoria Per-document RAM limit (MB) Limite RAM per documento (MB) Installed RAM: {ram} MB; this limit: {percent}% RAM installata: {ram} MB; questo limite: {percent}% @@ -4371,5 +4473,28 @@ shape Appearance Aspetto DDS Options Opzioni DDS Generate Mipmaps Genera mipmap +# Color Table (.act) dialog + There is no color table to save. Non c'è alcuna tabella colori da salvare. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Salvato {n} colore in {file}|Salvati {n} colori in {file} + # Graphics Adapter preference (#1885) GPU adapter Adattatore GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Conferma o annulla prima la finestra di dialogo aperta del passo dell’azione + Pro Medium Gray Pro grigio medio + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro grigio chiaro + Pro Light Pro chiaro + Resize Windows to Fit Ridimensiona finestre per adattare + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Direzione del paragrafo automatica + Left-to-right paragraph direction Direzione del paragrafo da sinistra a destra + Right-to-left paragraph direction Direzione del paragrafo da destra a sinistra + Not rendered: this adjustment ({key}) is kept as imported. Non renderizzato: questa regolazione ({key}) viene conservata come importata. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9b121c4c90..214a56fd93 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -15,6 +15,10 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… マスクオプション… + Layer Mask Display Options… レイヤーマスクの表示オプション… + # Channel Options and New Spot Channel (#3761) Channel Color チャンネルカラー Color Indicates: 着色表示: @@ -328,6 +332,7 @@ Blending Options… レイヤー効果… Bevel & Emboss… ベベルとエンボス… Use Global Light グローバルライトを使用 + Overprint オーバープリント Inner Shadow… シャドウ (内側)… Inner Glow… 光彩 (内側)… Satin… サテン… @@ -1092,7 +1097,7 @@ Done 完了 Dots ドット Drag a rectangle around the object オブジェクトを囲む矩形をドラッグ - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 画像上をドラッグして制約を追加。Shift で水平/垂直、右クリックで削除。 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 画像上をドラッグして制約を追加。Shift で水平/垂直、制約を右クリックで変更・削除。 Drag to move the active shape's path or the Work Path ドラッグして、アクティブなシェイプのパスまたは作業用パスを移動 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath クリック: アンカーポイントを選択 · ドラッグ: ポイント、ハンドル、セグメントを移動 · {shift}+クリック: 追加 · {alt}+クリック: サブパス全体 Draw with the Pen tool (P) or make a work path from a selection. ペンツール(P)で描くか、選択範囲から作業用パスを作成します。 @@ -1334,6 +1339,8 @@ Override Pressure 筆圧を上書き Override Rotation 回転を上書き Override Tilt 傾きを上書き + Override Tilt X 傾き X を上書き + Override Tilt Y 傾き Y を上書き PRESET DETAILS プリセットの詳細 PSD PSD Paint Overlay ペイントオーバーレイ @@ -1485,6 +1492,7 @@ Set shape fill type シェイプの塗りの種類を設定 Set shape stroke type シェイプの線の種類を設定 Set the text color 文字色を設定 + Create warped text ワープテキストを作成 Shadows シャドウ Shape: シェイプ: Sharpening シャープ @@ -1991,6 +1999,7 @@ Quick export format クイック書き出しの形式 Quick export location クイック書き出しの保存先 Recent file count 最近使ったファイルの数 + LUT folder LUT フォルダー Recent files 最近使ったファイル Recent fonts 最近使ったフォント Recover on launch 起動時に復元 @@ -2233,6 +2242,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU アクセラレーションを継続できませんでした。ドキュメントは変更されていません。 PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft は CPU で画像を合成しています。ウィンドウは引き続きグラフィックスアダプターを使用する場合があります。 Retry GPU requires a restart. Save your work first. GPU の再試行には再起動が必要です。先に作業を保存してください。 + Retry GPU tries the graphics processor again now. GPU を再試行すると、すぐにグラフィックプロセッサーをもう一度試します。 + GPU acceleration resumed. GPU アクセラレーションを再開しました。 Keep Using CPU CPU を使い続ける Retry GPU GPU を再試行 Advanced 詳細設定 @@ -2286,6 +2297,11 @@ cameraRaw Color カラー Content-Aware Move Tool コンテンツに応じた移動ツール TIFF Options TIFF オプション Targa Options Targa オプション + JPEG Options JPEG オプション + Image Options 画像オプション + Format Options 形式オプション + Baseline ("Standard") ベースライン(標準) + Baseline Optimized ベースライン(最適化) 24 bits/pixel 24 ビット/ピクセル 32 bits/pixel 32 ビット/ピクセル Including layers will increase file size. レイヤーを含めるとファイルサイズが大きくなります。 @@ -2876,6 +2892,7 @@ fontWeight Italic 斜体 Duplicate Layer Comp レイヤーカンプを複製 Duplicate Layers レイヤーを複製 Duplicate Path パスを複製 + Rename Path… パス名を変更… Dust & Scratches ダスト&スクラッチ EXIF EXIF Export As 書き出し形式を指定 @@ -3047,6 +3064,8 @@ fontWeight Italic 斜体 Cooling80 寒色フィルター (80) Cooling82 寒色フィルター (82) Copyright Status 著作権の状態 + Copyright Notice 著作権表示 + Document Title ドキュメントタイトル Copyrighted 著作権あり Corners 角 Correct C A 色収差を補正 @@ -3667,6 +3686,32 @@ cameraData Lens レンズ Mirror ミラー Output Settings 出力設定 Output To 出力先 + + Animation Options アニメーションオプション + Entire Document ドキュメント全体 + Filter Options フィルターオプション + Layer Bounds レイヤーの境界 + Layers Panel Options レイヤーパネルオプション + New Artboard… 新規アートボード… + New Group… 新規グループ… + Prevent Auto-Nesting 自動的な入れ子を防ぐ + Show Layers レイヤーを表示 + Thumbnail Contents サムネールの内容 + Thumbnail Size サムネールのサイズ + Use Default Masks on Fill Layers 塗りつぶしレイヤーにデフォルトのマスクを使用 + Expand New Effects 新しい効果を展開 + Add “copy” to Copied Layers and Groups コピーしたレイヤーとグループの名前に「コピー」を追加 + Preview on canvas when reordering layers レイヤーの並べ替え時にカンバスでプレビュー + Show layer mask badges レイヤーマスクのバッジを表示 + +# Smart Filter editors + Blending Options ({filter}) 描画オプション ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) レイヤー効果とスマートフィルターを非表示 ({key}+クリック: すべてのレイヤー) + Show the layer's effects and Smart Filters ({key}-click: all layers) レイヤー効果とスマートフィルターを表示 ({key}+クリック: すべてのレイヤー) + Wait for the current job to finish. 現在の処理が完了するまでお待ちください。 + The document or Smart Filter changed. Reopen its dialog. ドキュメントまたはスマートフィルターが変更されました。ダイアログを開き直してください。 + Opacity must be between 0% and 100%. 不透明度は0%から100%の範囲で指定してください。 + Dissolve is approximated: it blends without Photoshop's dither. ディザ合成は近似です。Photoshop のディザなしでブレンドします。 Loading fonts… フォントを読み込み中… Response curve 応答カーブ Simulate Pressure 筆圧をシミュレート @@ -3710,6 +3755,10 @@ curves Smooth スムーズ Click to place · Alt-click or drag off-canvas to remove クリックで配置 · Alt キーを押しながらクリック、またはカンバス外へドラッグで削除 No document open ドキュメントが開かれていません {n} of {max} sample points {max} 個中 {n} 個のサンプルポイント + +# History panel menu (#2966) + New Snapshot… 新規スナップショット… + History Options… ヒストリーオプション… # Print dialog (#2697) Emulsion Down 乳剤面を下 Functions 機能 @@ -3744,14 +3793,24 @@ curves Smooth スムーズ Light Gray ライトグレー Object Finder オブジェクトファインダー No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels アクティブなレイヤーのここにはピクセルがありません — 「すべてのレイヤーをサンプル」をオンにするか、ピクセルのあるレイヤーを選択してください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Load Contours… カーブを読み込み… + Replace Contours… カーブを置き換え… + Save Contours… カーブを保存… + Delete Contour カーブを削除 + Loaded contours from {file} {file} からカーブを読み込みました + Saved contours to {file} カーブを {file} に保存しました Show home recent files ホーム画面に最近使ったファイルを表示 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… Save Actions… アクションを保存… + New Action… 新規アクション… + Start Recording 記録を開始 + Stop Recording 記録を停止 {name} (Photoshop) {name}(Photoshop) {message} ({note}) {message}({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} からアクション {n} 個を読み込みました @@ -3853,6 +3912,16 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです +# History panel › History Brush source (#2966) + Paints from the History panel's source state ヒストリーパネルのソース状態からペイントします + Sets the source for the history brush ヒストリーブラシのソースを設定します +# Bevel & Emboss shading controls. + Highlight Mode ハイライトモード + Highlight Color ハイライトカラー + Highlight Opacity ハイライト不透明度 + Shadow Mode シャドウモード + Shadow Color シャドウカラー + Shadow Opacity シャドウ不透明度 # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) 埋め込みプロファイルを使用 ({profile}) Convert to the working space ({profile}) 作業用スペースに変換 ({profile}) @@ -3868,9 +3937,13 @@ curves Smooth スムーズ Chisel Hard チゼルハード Chisel Soft チゼルソフト Open color-less EXR as ACEScg 色空間のないEXRをACEScgとして開く + Do not append "copy" to filename when saving a copy コピーを保存するときにファイル名に「copy」を付けない # View › Guides › right-click a guide Edit Selected Guides 選択したガイドを編集 Clear Selected Guides 選択したガイドを消去 + Bicubic Automatic バイキュービック法 (自動) + Bicubic Smoother バイキュービック法 - 滑らか + Bicubic Sharper バイキュービック法 - シャープ Pasting from other apps is unavailable 他のアプリからの貼り付けは利用できません PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. このセッションでは PhotoCraft がシステムのクリップボードにアクセスできません。Wayland では、Flatpak のサンドボックスが他のアプリの画像を読み取るためのプロトコルを隠します。 To paste from other apps, start PhotoCraft under XWayland: `{command}` 他のアプリから貼り付けるには、XWayland 上で PhotoCraft を起動してください: `{command}` @@ -3890,6 +3963,17 @@ curves Smooth スムーズ set name セット名 {set} (modified) {set}(変更あり) Show file name in title タイトルにファイル名を表示 + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50% しきい値 + Pattern Dither パターンディザ + Diffusion Dither 拡散ディザ + Halftone Screen ハーフトーンスクリーン + Custom Pattern カスタムパターン + Frequency: 線数: + Lines/Inch 線/インチ + Lines/Centimeter 線/センチメートル + Ellipse 楕円 Accept and Go to Conflict 適用して競合へ移動 Changes undone. 変更を取り消しました。 Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。 @@ -3907,9 +3991,11 @@ curves Smooth スムーズ GPU Memory Full GPU メモリ不足 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. レイヤーには {needed} の GPU メモリが必要ですが、使えるのは {budget} のため、ドキュメント全体を再描画する編集は CPU で行われます。クリックして GPU メモリを設定します。 GPU memory budget: {budget} GPU メモリ予算: {budget} + Graphics memory: {size} グラフィックメモリ: {size} This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) + {path}: the path is not valid Unicode; save under another name. {path}: パスが有効な Unicode ではありません。別の名前で保存してください。 Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 @@ -3917,6 +4003,22 @@ curves Smooth スムーズ Increase Brush Size ブラシサイズを大きく Decrease Brush Hardness ブラシの硬さを下げる Increase Brush Hardness ブラシの硬さを上げる + Demosaic デモザイク + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. カメラのカラーフィルター配列をどのように補間するか。ファイルを開くときに適用されます。プレビューは開いたときの方式で表示されます。 + AHD (adaptive homogeneity) AHD(適応的均質性) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:エッジが最もきれいで色にじみが最も少ないが、最も遅い。 + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 勾配補正補間:シャープで高速だが、細部に多少の色にじみが出る。 + The fastest and the softest, with colour fringing on fine detail. 最も高速で最も柔らかく、細部に色にじみが出る。 + “{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか? + The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。 + Move Up 上へ移動 + Move Down 下へ移動 + Previous Brush 前のブラシ + Next Brush 次のブラシ + First Brush 最初のブラシ + Last Brush 最後のブラシ + Preview size Preview size Memory Usage メモリ使用量 Per-document RAM limit (MB) ドキュメントあたりの RAM 上限 (MB) Installed RAM: {ram} MB; this limit: {percent}% 搭載 RAM: {ram} MB、この上限: {percent}% @@ -3932,5 +4034,28 @@ curves Smooth スムーズ Compression 圧縮 Generate Mipmaps ミップマップを生成 +# Color Table (.act) dialog + There is no color table to save. 保存するカラーテーブルがありません。 +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file} に {n} 色を保存しました + # Graphics Adapter preference (#1885) GPU adapter GPU アダプター +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first 先に開いているアクションのステップのダイアログを確定またはキャンセルしてください + Pro Medium Gray プロ(中間グレー) + Solarized Solarized + Breeze Breeze + Pro Light Gray プロ(ライトグレー) + Pro Light プロ(ライト) + Resize Windows to Fit ウィンドウサイズを合わせる + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 段落の方向を自動判別 + Left-to-right paragraph direction 左から右への段落方向 + Right-to-left paragraph direction 右から左への段落方向 + Not rendered: this adjustment ({key}) is kept as imported. レンダリングされません: この調整({key})はインポートされたまま保持されます。 + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index dc4bf58c95..00bf8dff22 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -7,6 +7,10 @@ # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. # Product names and technology names retain their spelling. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… 마스크 옵션… + Layer Mask Display Options… 레이어 마스크 표시 옵션… + # Channel Options and New Spot Channel (#3761) Channel Color 채널 색상 Color Indicates: 색상 표시 내용: @@ -789,6 +793,22 @@ Split Warp Vertically 뒤틀기 세로로 분할 Statistics… 통계… Refine Strokes 다듬기 획 +# Counted status and panel messages +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 색상 견본 {n}개를 {file}(으)로 내보냈습니다 +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 색상 견본 {n}개를 가져왔습니다 +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group}에서 브러시 {n}개를 가져왔습니다 +@plural Played {n} step|Played {n} steps 단계 {n}개를 재생했습니다 +@plural {n} step|{n} steps 단계 {n}개 +@plural Saved for Web: {n} file|Saved for Web: {n} files 웹용으로 저장됨: 파일 {n}개 +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 디스크에서 변경된 연결된 스마트 오브젝트 {n}개를 업데이트했습니다 +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 이 레이어 구성 요소에 기록된 레이어 {n}개가 삭제되었습니다. 레이어 구성 요소를 업데이트하거나 경고를 지우세요. +@plural {n} plane|{n} planes 평면 {n}개 +@plural {n} preset|{n} presets 사전 설정 {n}개 +@plural {n} sample point removed|{n} sample points removed 샘플 지점 {n}개를 제거했습니다 +@plural {n} slice|{n} slices 분할 영역 {n}개 +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (브러시 사전 설정 경고 {n}개 더 있음) +@plural {what} with {n} warning|{what} with {n} warnings {what}: 경고 {n}개 + @plural {n} item|{n} items 항목 {n}개 Auto 자동 (print to the printer) (프린터로 인쇄) @@ -970,6 +990,9 @@ Copper 구리 Copyright 저작권 Copyright and Contact Info 저작권 및 연락처 정보 + Copyright Info URL 저작권 정보 URL + Copyright Notice 저작권 고지 + Document Title 문서 제목 Corner Crop Marks 모서리 자르기 표시 Corner radius 모서리 반경 Correction 보정 @@ -1071,7 +1094,7 @@ Done 완료 Dots 점 Drag a rectangle around the object 오브젝트를 둘러싸는 사각형을 드래그하세요 - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 오른쪽 클릭하여 삭제. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 이미지 위에서 드래그하여 제약 조건 추가; Shift 키로 수평/수직 유지; 제약 조건을 오른쪽 클릭하여 변경 또는 삭제. Drag to move the active shape's path or the Work Path 드래그하여 활성 모양의 패스 또는 작업 패스 이동 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 클릭: 기준점 선택 · 드래그: 기준점, 핸들 또는 세그먼트 이동 · {shift}-클릭: 추가 · {alt}-클릭: 하위 패스 전체 Draw with the Pen tool (P) or make a work path from a selection. 펜 도구(P)로 그리거나 선택 영역에서 작업 패스를 만드세요. @@ -1308,6 +1331,8 @@ Override Pressure 필압 재정의 Override Rotation 회전 재정의 Override Tilt 기울기 재정의 + Override Tilt X X축 기울기 재정의 + Override Tilt Y Y축 기울기 재정의 PRESET DETAILS 사전 설정 세부 정보 PSD PSD Paint Overlay 오버레이 칠하기 @@ -1392,6 +1417,7 @@ Rename 이름 바꾸기 Rename Group… 그룹 이름 바꾸기… Rename Layer… 레이어 이름 바꾸기… + Rename Path… 패스 이름 바꾸기… Rename Tool Preset… 도구 사전 설정 이름 바꾸기… Rename… 이름 바꾸기… Resample 리샘플링 @@ -1459,6 +1485,7 @@ Set shape fill type 모양 채우기 유형 설정 Set shape stroke type 모양 획 유형 설정 Set the text color 텍스트 색상 설정 + Create warped text 뒤틀린 텍스트 만들기 Shadows 어두운 영역 Shape: 모양: Sharpening 선명 효과 @@ -1959,6 +1986,7 @@ Quick export format 빠른 내보내기 형식 Quick export location 빠른 내보내기 위치 Recent file count 최근 파일 수 + LUT folder LUT 폴더 Recent files 최근 파일 Recent fonts 최근 글꼴 Recover on launch 시작 시 복구 @@ -2190,6 +2218,7 @@ 32 bit (float) 32비트(부동 소수점) Floating point 부동 소수점 Use Global Light 전체 조명 사용 + Overprint 중복 인쇄 Native file drag-and-drop is unavailable 파일 드래그 앤 드롭을 사용할 수 없습니다 Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland에서는 아직 파일 드래그 앤 드롭을 지원하지 않습니다. 파일 › 열기를 사용하거나, 파일 관리자에서 이미지를 복사한 뒤 {paste}(으)로 붙여넣으세요. To drop files, start PhotoCraft under XWayland: `{command}` 파일을 끌어다 놓으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}` @@ -2211,6 +2240,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU 가속을 계속할 수 없습니다. 문서는 변경되지 않았습니다. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft가 CPU로 이미지를 합성하고 있습니다. 창 표시에는 그래픽 어댑터를 계속 사용할 수 있습니다. Retry GPU requires a restart. Save your work first. GPU를 다시 사용하려면 다시 시작해야 합니다. 먼저 작업을 저장하세요. + Retry GPU tries the graphics processor again now. GPU 다시 사용을 누르면 바로 그래픽 프로세서를 다시 시도합니다. + GPU acceleration resumed. GPU 가속을 다시 사용합니다. Keep Using CPU CPU 계속 사용 Retry GPU GPU 다시 사용 Advanced 고급 @@ -2903,6 +2934,11 @@ cameraRaw Color 색상 Content-Aware Move Tool 내용 인식 이동 도구 TIFF Options TIFF 옵션 Targa Options Targa 옵션 + JPEG Options JPEG 옵션 + Image Options 이미지 옵션 + Format Options 포맷 옵션 + Baseline ("Standard") 기본("표준") + Baseline Optimized 기본 최적화 24 bits/pixel 24비트/픽셀 32 bits/pixel 32비트/픽셀 Including layers will increase file size. 레이어를 포함하면 파일 크기가 커집니다. @@ -3242,6 +3278,35 @@ cameraData Lens 렌즈 Mirror 미러 Output Settings 출력 설정 Output To 출력 위치 + + Animation Options 애니메이션 옵션 + Entire Document 전체 문서 + Filter Options 필터 옵션 + Layer Bounds 레이어 경계 + Layers Panel Options 레이어 패널 옵션 + New Artboard… 새 아트보드… + New Group 새 그룹 + New Group… 새 그룹… + New Layer… 새 레이어… + Panel Options… 패널 옵션… + Prevent Auto-Nesting 자동 중첩 방지 + Show Layers 레이어 표시 + Thumbnail Contents 축소판 내용 + Thumbnail Size 축소판 크기 + Use Default Masks on Fill Layers 칠 레이어에 기본 마스크 사용 + Expand New Effects 새 효과 펼치기 + Add “copy” to Copied Layers and Groups 복사한 레이어 및 그룹에 “복사” 추가 + Preview on canvas when reordering layers 레이어 순서 변경 시 캔버스에서 미리보기 + Show layer mask badges 레이어 마스크 배지 표시 + +# Smart Filter editors + Blending Options ({filter}) 혼합 옵션 ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) 레이어 효과 및 스마트 필터 숨기기 ({key}+클릭: 모든 레이어) + Show the layer's effects and Smart Filters ({key}-click: all layers) 레이어 효과 및 스마트 필터 표시 ({key}+클릭: 모든 레이어) + Wait for the current job to finish. 현재 작업이 완료될 때까지 기다리세요. + The document or Smart Filter changed. Reopen its dialog. 문서 또는 스마트 필터가 변경되었습니다. 대화 상자를 다시 여세요. + Opacity must be between 0% and 100%. 불투명도는 0%에서 100% 사이여야 합니다. + Dissolve is approximated: it blends without Photoshop's dither. 디졸브는 근사치입니다. Photoshop의 디더링 없이 혼합됩니다. Loading fonts… 글꼴 불러오는 중… Response curve 응답 곡선 Simulate Pressure 압력 시뮬레이션 @@ -3291,6 +3356,13 @@ curves Smooth 매끄럽게 Click to place · Alt-click or drag off-canvas to remove 클릭하여 배치 · Alt-클릭 또는 캔버스 밖으로 드래그하여 제거 No document open 열린 문서 없음 {n} of {max} sample points {max}개 중 {n}개의 샘플 지점 + +# History panel menu (#2966) + Step Forward 한 단계 앞으로 + Step Backward 한 단계 뒤로 + Clear History 작업 내역 지우기 + New Snapshot… 새 스냅샷… + History Options… 작업 내역 옵션… # Print dialog (#2697) Emulsion Down 감광면 아래로 Functions 기능 @@ -3311,9 +3383,16 @@ curves Smooth 매끄럽게 Stop 정지 Object Finder 개체 찾기 No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 활성 레이어의 이 위치에는 픽셀이 없습니다 — 모든 레이어 샘플링을 켜거나 픽셀이 있는 레이어를 선택하세요 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Load Contours… 윤곽선 불러오기… + Replace Contours… 윤곽선 바꾸기… + Save Contours… 윤곽선 저장… + Delete Contour 윤곽선 삭제 + Loaded contours from {file} {file}에서 윤곽선을 불러왔습니다 + Saved contours to {file} 윤곽선을 {file}에 저장했습니다 Show home recent files 홈 화면에 최근 파일 표시 Clear Brush Controls 브러시 제어 지우기 Reset All Locked Settings 모든 잠긴 설정 재설정 @@ -3322,6 +3401,9 @@ curves Smooth 매끄럽게 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 액션 불러오기… Save Actions… 액션 저장… + New Action… 새 액션… + Start Recording 기록 시작 + Stop Recording 기록 중지 {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}에서 액션 {n}개를 불러왔습니다 @@ -3353,6 +3435,16 @@ curves Smooth 매끄럽게 Sent to {printer} {printer}(으)로 전송됨 set in Print Settings… 인쇄 설정에서 지정… ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 +# History panel › History Brush source (#2966) + Paints from the History panel's source state 작업 내역 패널의 소스 상태에서 칠합니다 + Sets the source for the history brush 작업 내역 브러시의 소스를 설정합니다 +# Bevel & Emboss shading controls. + Highlight Mode 하이라이트 모드 + Highlight Color 하이라이트 색상 + Highlight Opacity 하이라이트 불투명도 + Shadow Mode 그림자 모드 + Shadow Color 그림자 색상 + Shadow Opacity 그림자 불투명도 # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) 포함된 프로파일 사용({profile}) Convert to the working space ({profile}) 작업 색상 공간으로 변환({profile}) @@ -3369,6 +3461,7 @@ curves Smooth 매끄럽게 Chisel Hard 단단하게 조각 Chisel Soft 부드럽게 조각 Open color-less EXR as ACEScg 색 공간 정보 없는 EXR을 ACEScg로 열기 + Do not append "copy" to filename when saving a copy 사본 저장 시 파일 이름에 "copy"를 붙이지 않음 # Export As metadata and profile options Color Profile 색상 프로필 EXIF EXIF @@ -3376,6 +3469,10 @@ curves Smooth 매끄럽게 # View › Guides › right-click a guide Edit Selected Guides 선택한 안내선 편집 Clear Selected Guides 선택한 안내선 지우기 + Make Selection 선택 영역 만들기 + Bicubic Automatic 쌍입방 자동 + Bicubic Smoother 쌍입방 더 부드럽게 + Bicubic Sharper 쌍입방 더 선명하게 Pasting from other apps is unavailable 다른 앱에서 붙여넣기를 사용할 수 없습니다 PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. 이 세션에서는 PhotoCraft가 시스템 클립보드에 접근할 수 없습니다. Wayland에서는 Flatpak 샌드박스가 다른 앱의 이미지를 읽는 데 쓰이는 프로토콜을 숨깁니다. To paste from other apps, start PhotoCraft under XWayland: `{command}` 다른 앱에서 붙여넣으려면 XWayland에서 PhotoCraft를 실행하세요: `{command}` @@ -3395,6 +3492,17 @@ curves Smooth 매끄럽게 set name 세트 이름 {set} (modified) {set}(수정됨) Show file name in title 제목에 파일 이름 표시 + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50% 한계값 + Pattern Dither 패턴 디더 + Diffusion Dither 확산 디더 + Halftone Screen 하프톤 스크린 + Custom Pattern 사용자 정의 패턴 + Frequency: 빈도: + Lines/Inch 선/인치 + Lines/Centimeter 선/센티미터 + Ellipse 타원 Accept and Go to Conflict 적용 후 충돌 항목으로 이동 Changes undone. 변경 사항을 취소했습니다. Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요. @@ -3404,6 +3512,7 @@ curves Smooth 매끄럽게 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) # Free Transform: a new layer while the box is open (#5859) + Reference point location 기준점 위치 Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. The transform couldn't be applied. 변형을 적용할 수 없습니다. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -3412,9 +3521,11 @@ curves Smooth 매끄럽게 GPU Memory Full GPU 메모리 부족 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 레이어에 GPU 메모리 {needed}가 필요하지만 {budget}만 사용할 수 있어 문서 전체를 다시 그리는 편집은 CPU에서 처리됩니다. 클릭하여 GPU 메모리를 설정합니다. GPU memory budget: {budget} GPU 메모리 예산: {budget} + Graphics memory: {size} 그래픽 메모리: {size} This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) + {path}: the path is not valid Unicode; save under another name. {path}: 경로가 올바른 유니코드가 아닙니다. 다른 이름으로 저장하세요. Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. @@ -3422,6 +3533,22 @@ curves Smooth 매끄럽게 Increase Brush Size 브러시 크기 늘리기 Decrease Brush Hardness 브러시 경도 줄이기 Increase Brush Hardness 브러시 경도 늘리기 + Demosaic 디모자이크 + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 카메라 컬러 필터 모자이크를 채우는 방법입니다. 파일을 열 때 적용되며, 미리 보기는 파일을 연 방식으로 표시됩니다. + AHD (adaptive homogeneity) AHD (적응형 균질성) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: 가장 깨끗한 가장자리와 가장 적은 색 번짐, 가장 느림. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 기울기 보정 보간: 선명하고 빠르지만 미세한 디테일에 약간의 색 번짐이 있음. + The fastest and the softest, with colour fringing on fine detail. 가장 빠르고 가장 부드러우며, 미세한 디테일에 색 번짐이 있음. + “{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까? + The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}” + Move Up 위로 이동 + Move Down 아래로 이동 + Previous Brush 이전 브러시 + Next Brush 다음 브러시 + First Brush 첫 번째 브러시 + Last Brush 마지막 브러시 + Preview size Preview size Memory Usage 메모리 사용량 Per-document RAM limit (MB) 문서당 RAM 한도(MB) Installed RAM: {ram} MB; this limit: {percent}% 설치된 RAM: {ram} MB; 이 한도: {percent}% @@ -3438,5 +3565,28 @@ curves Smooth 매끄럽게 Compression 압축 Generate Mipmaps 밉맵 생성 +# Color Table (.act) dialog + There is no color table to save. 저장할 색상표가 없습니다. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {file}에 색상 {n}개 저장 + # Graphics Adapter preference (#1885) GPU adapter GPU 어댑터 +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first 먼저 열려 있는 액션 단계 대화 상자를 확인하거나 취소하세요 + Pro Medium Gray 프로 중간 회색 + Solarized Solarized + Breeze Breeze + Pro Light Gray 프로 밝은 회색 + Pro Light 프로 밝게 + Resize Windows to Fit 창 크기 맞추기 + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 자동 단락 방향 + Left-to-right paragraph direction 왼쪽에서 오른쪽 단락 방향 + Right-to-left paragraph direction 오른쪽에서 왼쪽 단락 방향 + Not rendered: this adjustment ({key}) is kept as imported. 렌더링되지 않음: 이 조정({key})은 가져온 상태 그대로 유지됩니다. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 7d8d6457ef..e2150247b9 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -4,6 +4,13 @@ use super::*; use crate::{PhotocraftApp, Services}; use serde_json::json; +/// The File menu's title as the menu bar draws it in `lang`: the translation, plus the English +/// access key where it has no Latin letter of its own ("文件(F)", #5863). +fn file_title(lang: Lang) -> String { + let (shown, _) = crate::menu_keys::localized(&[tr(lang, "File").to_string()], &["File"], &[Some('F')]); + shown.into_iter().next().unwrap_or_default() +} + fn lang(code: &str) -> Lang { Lang::from_code(code).expect("registered language") } @@ -265,7 +272,7 @@ fn first_launch_renders_the_system_language_without_overwriting_auto() { let h = harness(); assert_eq!(h.state().session.prefs().interface.language, "auto"); assert_eq!(current(), lang(code), "{tag}"); - assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang(code), "File")), "first-launch menu: {tag}"); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "first-launch menu: {tag}"); }); }); } @@ -316,7 +323,7 @@ fn an_explicit_language_survives_restart_on_a_different_system_language() { let h = harness_with_services(services); assert_eq!(h.state().session.prefs().interface.language, "ko"); assert_eq!(current(), lang("ko")); - assert!(drawn_text(&h).iter().any(|text| text.as_str() == tr(lang("ko"), "File"))); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang("ko")))); }); }); } @@ -335,7 +342,7 @@ fn a_control_request_changes_the_drawing_language_in_the_same_frame() { h.step(); assert_eq!(reply.try_recv().expect("response")["ok"], true); assert_eq!(current(), lang(code)); - assert!(drawn_text(&h).iter().any(|text| text == tr(lang(code), "File")), "the menu must change in this frame: {code}"); + assert!(drawn_text(&h).iter().any(|text| *text == file_title(lang(code))), "the menu must change in this frame: {code}"); assert!(std::sync::Arc::ptr_eq(&document, &h.state().session.active().expect("document").doc), "switching language must preserve the document"); } assert!(h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": []})).is_err()); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 39eaaeed59..0ff8a30432 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 21] = [ +pub static LANGUAGES: [LangInfo; 22] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -220,6 +220,8 @@ pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese; `vi-VN` resolves here. LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, + // Hindi; `hi`, `hi-IN` locales resolve here. + LangInfo { code: "hi", name: "हिन्दी", source: catalog!("hi"), plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -280,6 +282,16 @@ impl Lang { LANGUAGES.iter().map(Lang) } + /// Every registered language in the order a picker lists them: English first (the source + /// language and the fallback), then the rest by their own name with accents folded, so + /// Čeština sits under C and Türkçe under T rather than after Z. Names in other scripts follow + /// the Latin ones in code point order: Greek, Cyrillic, then the CJK languages. + pub fn for_picker() -> Vec { + let mut rest: Vec = Lang::all().filter(|l| l.code() != "en").collect(); + rest.sort_by_cached_key(|l| name_sort_key(l.name())); + std::iter::once(Lang::EN).chain(rest).collect() + } + pub fn name(self) -> &'static str { self.0.name } @@ -299,6 +311,27 @@ impl Lang { } } +/// `name` lowercased with the Latin accents that occur in language names folded away, so a +/// byte-wise sort reads alphabetically. Other scripts are left as they are. +fn name_sort_key(name: &str) -> String { + name.chars() + .flat_map(char::to_lowercase) + .map(|c| match c { + 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'č' => 'c', + 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e', + 'ì'..='ï' | 'ī' => 'i', + 'ñ' | 'ń' | 'ň' => 'n', + 'ò'..='ö' | 'ø' | 'ő' => 'o', + 'ś' | 'š' => 's', + 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u', + 'ý' | 'ÿ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + c => c, + }) + .collect() +} + /// Candidate language codes for a locale tag, most specific first: `zh_TW.UTF-8` → /// `zh-tw`, `zh-hant`, `zh`. fn candidates(tag: &str) -> Vec { @@ -436,6 +469,21 @@ mod tests { const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered"); const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered"); const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered"); + const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered"); + + #[test] + fn hindi_tags_resolve() { + for tag in ["hi", "hi-IN", "hi_IN", "hi_IN.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(HI()), "{tag}"); + } + assert_eq!(HI().name(), "हिन्दी"); + assert_eq!(Lang::from_pref("hi"), HI()); + assert_eq!(Lang::from_pref("HI"), HI()); + assert_eq!(tr(HI(), "Layer"), "लेयर"); + assert_eq!(tr(HI(), "File"), "फ़ाइल"); + assert_eq!(trn(HI(), 1, "{n} layer", "{n} layers"), "1 लेयर"); + assert_eq!(trn(HI(), 3, "{n} layer", "{n} layers"), "3 लेयर्स"); + } #[test] fn indonesian_tags_resolve() { @@ -456,6 +504,22 @@ mod tests { assert_eq!(Lang::from_pref("VI"), VI()); } + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = Lang::for_picker(); + assert_eq!(order.first().map(|l| l.code()), Some("en")); + assert_eq!(order.len(), Lang::all().count()); + let keys: Vec = order[1..].iter().map(|l| name_sort_key(l.name())).collect(); + assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}"); + let pos = |code: &str| order.iter().position(|l| l.code() == code).unwrap_or_else(|| panic!("{code} registered")); + assert!(pos("cs") < pos("de"), "Čeština sorts under C, not after Z"); + assert!(pos("it") < pos("hu"), "Magyar sorts under M, not under its code"); + assert!(pos("tr") < pos("el"), "Latin names come before Greek"); + assert!(pos("uk") < pos("ja"), "Cyrillic names come before CJK"); + assert_eq!(name_sort_key("Tiếng Việt"), "tieng viet"); + assert_eq!(name_sort_key("Türkçe"), "turkce"); + } + #[test] fn tags_map_to_languages() { assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA())); @@ -514,11 +578,21 @@ mod tests { assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); } - /// The brush size/hardness shortcut rows (`tools.*` commands without a menu item) must be translated in - /// every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found by searching. + /// The brush size/hardness and preset-stepping shortcut rows (`tools.*` commands without a menu item) must + /// be translated in every catalog, or they show English in the Keyboard Shortcuts dialog and cannot be found + /// by searching. #[test] fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { - let keys = ["Decrease Brush Size", "Increase Brush Size", "Decrease Brush Hardness", "Increase Brush Hardness"]; + let keys = [ + "Decrease Brush Size", + "Increase Brush Size", + "Decrease Brush Hardness", + "Increase Brush Hardness", + "Previous Brush", + "Next Brush", + "First Brush", + "Last Brush", + ]; for lang in Lang::all().filter(|lang| *lang != Lang::EN) { for key in keys { assert!(has(lang, key), "{} has no translation for {key:?}", lang.code()); @@ -906,6 +980,54 @@ mod tests { assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 1, 2, 2, 1]); } + /// Counted status messages read in Simplified Chinese instead of falling back to English (they were + /// missing from the catalog). + #[test] + fn simplified_chinese_counted_messages_are_translated() { + let lang = Lang::from_code("zh-hans").expect("zh-hans registered"); + assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "已播放 3 个步骤"); + let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "水彩")]), "已从 水彩 导入 2 个画笔"); + assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 个切片"); + } + + /// Counted status messages read in Korean instead of falling back to English (they were + /// missing from the catalog). + #[test] + fn korean_counted_messages_are_translated() { + let lang = Lang::from_code("ko").expect("ko registered"); + assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "단계 3개를 재생했습니다"); + let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "수채화")]), "수채화에서 브러시 2개를 가져왔습니다"); + assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "분할 영역 5개"); + } + + /// Counted status messages read in Indonesian instead of falling back to English (they were + /// missing from the catalog). + #[test] + fn indonesian_counted_messages_are_translated() { + let lang = Lang::from_code("id").expect("id registered"); + assert_eq!(trn(lang, 3, "Played {n} step", "Played {n} steps"), "3 langkah diputar"); + let brushes = trn(lang, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "Cat Air")]), "2 kuas diimpor dari Cat Air"); + assert_eq!(trn(lang, 5, "{n} slice", "{n} slices"), "5 slice"); + } + + /// Counted status messages read in Polish with all three forms instead of falling back to + /// English (they were missing from the catalog). + #[test] + fn polish_counted_messages_use_all_three_forms() { + let pl = Lang::from_code("pl").expect("pl registered"); + let steps = |n| trn(pl, n, "Played {n} step", "Played {n} steps"); + assert_eq!( + [steps(1), steps(3), steps(5), steps(12), steps(22)], + ["Odtworzono 1 krok", "Odtworzono 3 kroki", "Odtworzono 5 kroków", "Odtworzono 12 kroków", "Odtworzono 22 kroki"] + ); + let brushes = trn(pl, 2, "Imported {n} brush from {group}", "Imported {n} brushes from {group}"); + assert_eq!(fmt(&brushes, &[("group", "Akwarele")]), "Zaimportowano 2 pędzle z Akwarele"); + assert_eq!(trn(pl, 5, "{n} slice", "{n} slices"), "5 plasterków"); + } + #[test] fn czech_plural_rule() { let forms: Vec = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect(); @@ -1126,6 +1248,60 @@ mod tests { } } + /// Russian dynamic labels added in the fourth pass (filter parameters, Camera Raw panels, + /// hardware status, rasterize prompts, History/paths menus) render in Russian, not English. + #[test] + fn russian_dynamic_status_hardware_and_dialog_labels_are_translated() { + let ru = Lang::from_code("ru").expect("ru registered"); + for kind in [ + crate::rasterize_prompt::Kind::Type, + crate::rasterize_prompt::Kind::Shape, + crate::rasterize_prompt::Kind::SmartObject, + crate::rasterize_prompt::Kind::Fill, + ] { + assert_ne!(tr(ru, kind.message()), kind.message(), "{:?}", kind); + } + assert_eq!(fmt(tr(ru, "Driver: {driver}"), &[("driver", "Mesa 25.0")]), "Драйвер: Mesa 25.0"); + assert_eq!( + fmt(tr(ru, "Monitor profile on {display}: {profile}"), &[("display", "DELL U2720Q"), ("profile", "sRGB IEC61966-2.1")]), + "Профиль монитора для DELL U2720Q: sRGB IEC61966-2.1" + ); + assert_eq!(fmt(tr(ru, "Platform: {os} {arch}"), &[("os", "Linux"), ("arch", "x86_64")]), "Платформа: Linux x86_64"); + assert_eq!(tr(ru, "Graphics adapter"), "Графический адаптер"); + assert_eq!(tr(ru, "Image compositor: {processor}"), "Компоновщик изображения: {processor}"); + // Filter / adjustment parameter names. + for label in [ + "Amount", + "Radius", + "Threshold", + "Angle", + "High Pass", + "Smart Blur", + "Reduce Noise", + "Lens Correction", + "Colored Pencil", + "Angled Strokes", + "Stained Glass", + "Sumi-e", + "Film Grain", + "Unsharp Mask", + "Lens Flare", + ] { + assert!(has(ru, label), "Russian filter label missing: {label}"); + } + // Dynamic composed status templates. + assert_eq!( + fmt(tr(ru, "Step {step} ({id}) failed: {error}"), &[("step", "2"), ("id", "file.export"), ("error", "ok")]), + "Шаг 2 (file.export) не выполнен: ok" + ); + assert_eq!(fmt(tr(ru, "Couldn't open {name}: {error}"), &[("name", "scan.tif"), ("error", "denied")]), "Не удалось открыть scan.tif: denied"); + assert_eq!(fmt(tr(ru, "Exported {path} ({size})"), &[("path", "/tmp/a.png"), ("size", "12 KB")]), "Экспортировано: /tmp/a.png (12 KB)"); + assert_eq!(fmt(tr(ru, "Data Set: {name} ({index}/{total})"), &[("name", "Set 1"), ("index", "1"), ("total", "2")]), "Набор данных: Set 1 (1/2)"); + // Document and layer names stay verbatim. + let text = fmt(tr(ru, "Saved {path}; image assets: {n} in {dir}"), &[("path", "/tmp/a.pcraft"), ("n", "3"), ("dir", "/tmp/assets")]); + assert!(text.contains("/tmp/a.pcraft") && text.contains("/tmp/assets"), "{text}"); + } + /// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus /// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as /// they are (names, units) are listed in `KEEP_AS_IS`. @@ -1259,6 +1435,24 @@ mod tests { } } + /// The Italian catalog names the Dodge/Burn/Sponge *tools* and the blend modes the way Adobe + /// does. `Scherma` is the Dodge tool (with `Brucia` and `Spugna`) **and** the `Screen` blend + /// mode — Adobe's own collision, not ours — while `Schiarisci` is the `Lighten` blend mode and + /// must not be used for the tool. `Screen` used to read `Schermo`, "the display". + #[test] + fn italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does() { + let it = LANGUAGES.iter().find(|l| l.code == "it").expect("the Italian catalog is registered"); + for (source, translation) in [("Dodge", "Scherma"), ("Dodge Tool", "Strumento Scherma"), ("Dodge/Burn", "Scherma/Brucia"), ("Screen", "Scherma")] { + assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); + } + // The tool family stays together, and the blend modes keep Adobe's wording. + for (source, translation) in + [("Burn", "Brucia"), ("Sponge", "Spugna"), ("Lighten", "Schiarisci"), ("Color Dodge", "Colore scherma"), ("Linear Dodge", "Scherma lineare")] + { + assert_eq!(it.catalog().plain(source), Some(translation), "it: {source:?}"); + } + } + // Dynamic dialog labels generated by label_of() are not seen by the tl! scanner. #[test] fn color_lookup_export_scope_labels_are_translated() { diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 7104d69eda..472dab3980 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -8,6 +8,10 @@ # stay as they are; the trailing "…" of dialog commands is kept. UI text addresses the user # informally ("je") and uses sentence case. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maskeropties… + Layer Mask Display Options… Weergaveopties voor laagmasker… + # Channel Options and New Spot Channel (#3761) Channel Color Kanaalkleur Color Indicates: Kleur geeft aan: @@ -319,6 +323,7 @@ Blending Options… Overvloeiopties… Bevel & Emboss… Schuine kant en reliëf… Use Global Light Globaal licht gebruiken + Overprint Overdrukken Inner Shadow… Binnenschaduw… Inner Glow… Gloed binnen… Satin… Satijn… @@ -1073,7 +1078,7 @@ Done Gereed Dots Stippen Drag a rectangle around the object Sleep een rechthoek rond het object - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Sleep over de afbeelding om een beperking toe te voegen; Shift voor horizontaal/verticaal; rechtsklik om te verwijderen. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Sleep over de afbeelding om een beperking toe te voegen; Shift voor horizontaal/verticaal; rechtsklik op een beperking om deze te wijzigen of te verwijderen. Drag to move the active shape's path or the Work Path Sleep om het pad van de actieve vorm of het werkpad te verplaatsen Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: ankerpunt selecteren · Slepen: ankerpunt, greep of segment verplaatsen · {shift}-klik: toevoegen · {alt}-klik: heel subpad Draw with the Pen tool (P) or make a work path from a selection. Teken met het pengereedschap (P) of maak een werkpad van een selectie. @@ -1315,6 +1320,8 @@ Override Pressure Druk overschrijven Override Rotation Rotatie overschrijven Override Tilt Kanteling overschrijven + Override Tilt X Kanteling X overschrijven + Override Tilt Y Kanteling Y overschrijven PRESET DETAILS DETAILS VOORINSTELLING PSD PSD Paint Overlay Verfbedekking @@ -1466,6 +1473,7 @@ Set shape fill type Vultype van vorm instellen Set shape stroke type Lijntype van vorm instellen Set the text color De tekstkleur instellen + Create warped text Verbogen tekst maken Shadows Schaduwen Shape: Vorm: Sharpening Verscherping @@ -1963,6 +1971,7 @@ Quick export format Formaat snel exporteren Quick export location Locatie snel exporteren Recent file count Aantal recente bestanden + LUT folder LUT-map Recent files Recente bestanden Recent fonts Recente lettertypen Recover on launch Herstellen bij opstarten @@ -2279,6 +2288,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU-versnelling kon niet doorgaan. Je documenten zijn ongewijzigd. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft gebruikt de CPU-beeldcompositor. Het venster kan nog steeds je grafische adapter gebruiken. Retry GPU requires a restart. Save your work first. GPU opnieuw proberen vereist een herstart. Sla eerst je werk op. + Retry GPU tries the graphics processor again now. GPU opnieuw proberen probeert de grafische processor nu opnieuw. + GPU acceleration resumed. GPU-versnelling hervat. Keep Using CPU CPU blijven gebruiken Retry GPU GPU opnieuw proberen Advanced Geavanceerd @@ -2324,6 +2335,11 @@ Content-Aware Move Tool Inhoudsbewust verplaatsen TIFF Options TIFF-opties Targa Options Targa-opties + JPEG Options JPEG-opties + Image Options Afbeeldingsopties + Format Options Indelingsopties + Baseline ("Standard") Basislijn ("Standaard") + Baseline Optimized Basislijn geoptimaliseerd 24 bits/pixel 24 bits/pixel 32 bits/pixel 32 bits/pixel Including layers will increase file size. Lagen meenemen vergroot de bestandsgrootte. @@ -2835,6 +2851,8 @@ cameraRaw Color Kleur Keywords Trefwoorden Copyright Info URL URL copyrightinformatie Copyright Status Copyrightstatus + Copyright Notice Copyrightvermelding + Document Title Documenttitel Copyrighted Auteursrechtelijk beschermd Public Domain Publiek domein Unknown Onbekend @@ -3351,6 +3369,7 @@ cameraRaw Color Kleur Do you want to save you made to “{name}” before quitting? Wil je de wijzigingen in “{name}” opslaan voordat je afsluit? Dodge/Burn Tegenhouden/doordrukken Duplicate Path Pad dupliceren + Rename Path… Pad hernoemen… Dust & Scratches Stof en krassen Dynamics Dynamiek Edit Plane Vlak bewerken @@ -3811,6 +3830,32 @@ cameraData Lens Lens Mirror Spiegelen Output Settings Uitvoerinstellingen Output To Uitvoer naar + + Animation Options Animatieopties + Entire Document Hele document + Filter Options Filteropties + Layer Bounds Laaggrenzen + Layers Panel Options Opties van het deelvenster Lagen + New Artboard… Nieuw tekengebied… + New Group… Nieuwe groep… + Prevent Auto-Nesting Automatisch nesten voorkomen + Show Layers Lagen tonen + Thumbnail Contents Inhoud van miniaturen + Thumbnail Size Grootte van miniaturen + Use Default Masks on Fill Layers Standaardmaskers gebruiken op opvullagen + Expand New Effects Nieuwe effecten uitvouwen + Add “copy” to Copied Layers and Groups „kopie” toevoegen aan gekopieerde lagen en groepen + Preview on canvas when reordering layers Voorvertoning op canvas bij herschikken van lagen + Show layer mask badges Badges voor laagmaskers tonen + +# Smart Filter editors + Blending Options ({filter}) Overvloeiopties ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Laageffecten en slimme filters verbergen ({key}-klik: alle lagen) + Show the layer's effects and Smart Filters ({key}-click: all layers) Laageffecten en slimme filters tonen ({key}-klik: alle lagen) + Wait for the current job to finish. Wacht tot de huidige taak is voltooid. + The document or Smart Filter changed. Reopen its dialog. Het document of het slimme filter is gewijzigd. Open het dialoogvenster opnieuw. + Opacity must be between 0% and 100%. De dekking moet tussen 0% en 100% liggen. + Dissolve is approximated: it blends without Photoshop's dither. Verspreiden is benaderd: het mengt zonder de dither van Photoshop. Loading fonts… Lettertypen laden… Response curve Responscurve Simulate Pressure Druk simuleren @@ -3859,6 +3904,10 @@ curves Smooth Vloeiend maken # Color Sampler (added with the i18n coverage repair) Click to place · Alt-click or drag off-canvas to remove Klik om te plaatsen · Alt-klik of sleep buiten het canvas om te verwijderen {n} of {max} sample points {n} van {max} steekproefpunten + +# History panel menu (#2966) + New Snapshot… Nieuwe momentopname… + History Options… Geschiedenisopties… # Print dialog (#2697) Emulsion Down Emulsie omlaag Functions Functies @@ -3893,9 +3942,16 @@ curves Smooth Vloeiend maken Light Gray Lichtgrijs Object Finder Objectzoeker No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels De actieve laag heeft hier geen pixels — zet Alle lagen samplen aan of selecteer een laag met pixels Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Load Contours… Contouren laden… + Replace Contours… Contouren vervangen… + Save Contours… Contouren opslaan… + Delete Contour Contour verwijderen + Loaded contours from {file} Contouren geladen uit {file} + Saved contours to {file} Contouren opgeslagen in {file} Show home recent files Recente bestanden tonen op het startscherm Clear Brush Controls Penseelbesturing wissen Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen @@ -3904,6 +3960,9 @@ curves Smooth Vloeiend maken # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Handelingen laden… Save Actions… Handelingen opslaan… + New Action… Nieuwe handeling… + Start Recording Opname starten + Stop Recording Opname stoppen {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} handeling geladen uit {file}|{n} handelingen geladen uit {file} @@ -3935,6 +3994,16 @@ curves Smooth Vloeiend maken Sent to {printer} Verzonden naar {printer} set in Print Settings… instellen in de afdrukinstellingen… ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels +# History panel › History Brush source (#2966) + Paints from the History panel's source state Schildert vanuit de bronstatus van het deelvenster Geschiedenis + Sets the source for the history brush Stelt de bron voor het historiepenseel in +# Bevel & Emboss shading controls. + Highlight Mode Hooglichtmodus + Highlight Color Hooglichtkleur + Highlight Opacity Hooglichtdekking + Shadow Mode Schaduwmodus + Shadow Color Schaduwkleur + Shadow Opacity Schaduwdekking # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Het ingesloten profiel gebruiken ({profile}) Convert to the working space ({profile}) Omzetten naar de werkruimte ({profile}) @@ -3951,10 +4020,14 @@ curves Smooth Vloeiend maken Chisel Hard Beitel hard Chisel Soft Beitel zacht Open color-less EXR as ACEScg EXR zonder kleurruimte als ACEScg openen + Do not append "copy" to filename when saving a copy Geen "copy" aan de bestandsnaam toevoegen bij het opslaan van een kopie # View › Guides › right-click a guide Edit Selected Guides Geselecteerde hulplijnen bewerken Clear Selected Guides Geselecteerde hulplijnen wissen + Bicubic Automatic Bicubisch automatisch + Bicubic Smoother Bicubisch vloeiender + Bicubic Sharper Bicubisch scherper Pasting from other apps is unavailable Plakken vanuit andere apps is niet beschikbaar PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft heeft in deze sessie geen toegang tot het systeemklembord. Onder Wayland verbergt een Flatpak-sandbox het protocol waarmee afbeeldingen uit andere apps worden gelezen. To paste from other apps, start PhotoCraft under XWayland: `{command}` Om vanuit andere apps te plakken, start je PhotoCraft onder XWayland: `{command}` @@ -3974,6 +4047,17 @@ curves Smooth Vloeiend maken set name naam van de set {set} (modified) {set} (gewijzigd) Show file name in title Bestandsnaam in titel weergeven + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50% drempel + Pattern Dither Patroondithering + Diffusion Dither Diffusiedithering + Halftone Screen Halftoonraster + Custom Pattern Aangepast patroon + Frequency: Frequentie: + Lines/Inch Lijnen/inch + Lines/Centimeter Lijnen/centimeter + Ellipse Ellips Accept and Go to Conflict Accepteren en naar conflict Changes undone. Wijzigingen ongedaan gemaakt. Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder. @@ -3991,15 +4075,33 @@ curves Smooth Vloeiend maken GPU Memory Full GPU-geheugen vol The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. De lagen hebben {needed} GPU-geheugen nodig en mogen {budget} gebruiken, dus bewerkingen die het hele document opnieuw tekenen doen dat op de CPU. Klik om GPU-geheugen in te stellen. GPU memory budget: {budget} GPU-geheugenbudget: {budget} + Graphics memory: {size} Grafisch geheugen: {size} This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) + {path}: the path is not valid Unicode; save under another name. {path}: het pad is geen geldige Unicode; sla op onder een andere naam. {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. Decrease Brush Size Penseelgrootte verkleinen Increase Brush Size Penseelgrootte vergroten Decrease Brush Hardness Penseelhardheid verlagen Increase Brush Hardness Penseelhardheid verhogen + Demosaic Demosaicing + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Hoe het kleurfiltermozaïek van de camera wordt ingevuld. Wordt toegepast bij het openen van het bestand; de voorvertoning toont de methode waarmee het is geopend. + AHD (adaptive homogeneity) AHD (adaptieve homogeniteit) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: de schoonste randen en de minste kleurranden, het traagst. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradiëntgecorrigeerde interpolatie: scherp en snel, met wat kleurranden in fijne details. + The fastest and the softest, with colour fringing on fine detail. Het snelst en het zachtst, met kleurranden in fijne details. + “{name}” already exists. Replace it? “{name}” bestaat al. Vervangen? + The name in the save dialog was “{name}”. De naam in het venster Opslaan was “{name}”. + Move Up Omhoog + Move Down Omlaag + Previous Brush Vorig penseel + Next Brush Volgend penseel + First Brush Eerste penseel + Last Brush Laatste penseel + Preview size Preview size Memory Usage Geheugengebruik Per-document RAM limit (MB) RAM-limiet per document (MB) Installed RAM: {ram} MB; this limit: {percent}% Geïnstalleerd RAM: {ram} MB; deze limiet: {percent}% @@ -4014,5 +4116,28 @@ curves Smooth Vloeiend maken DDS Options DDS-opties Generate Mipmaps Mipmaps genereren +# Color Table (.act) dialog + There is no color table to save. Er is geen kleurtabel om op te slaan. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} kleur opgeslagen in {file}|{n} kleuren opgeslagen in {file} + # Graphics Adapter preference (#1885) GPU adapter GPU-adapter +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Bevestig of annuleer eerst het geopende dialoogvenster van de handelingsstap + Pro Medium Gray Pro middelgrijs + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro lichtgrijs + Pro Light Pro licht + Resize Windows to Fit Venstergrootte aanpassen + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatische alinearichting + Left-to-right paragraph direction Alinearichting van links naar rechts + Right-to-left paragraph direction Alinearichting van rechts naar links + Not rendered: this adjustment ({key}) is kept as imported. Niet weergegeven: deze aanpassing ({key}) blijft behouden zoals geïmporteerd. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8abed62baa..5744be49df 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -18,6 +18,10 @@ # Conventions: menus are nouns (Plik, Edycja, Obraz ...), commands are infinitives (Otwórz…, # Zapisz jako…), sentence case as in the English source. Quotes: „…”. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opcje maski… + Layer Mask Display Options… Opcje wyświetlania maski warstwy… + # Channel Options and New Spot Channel (#3761) Channel Color Kolor kanału Color Indicates: Kolor oznacza: @@ -326,6 +330,7 @@ Blending Options… Opcje mieszania… Bevel & Emboss… Ukos i płaskorzeźba… Use Global Light Użyj światła globalnego + Overprint Nadrukowywanie Inner Shadow… Cień wewnętrzny… Inner Glow… Poświata wewnętrzna… Satin… Satyna… @@ -810,6 +815,22 @@ Split Warp Vertically Podziel wypaczenie w pionie Statistics… Statystyki… Refine Strokes Pociągnięcia udoskonalania +# Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę koloru do {file}|Wyeksportowano {n} próbki kolorów do {file}|Wyeksportowano {n} próbek kolorów do {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę koloru z {file}|Zaimportowano {n} próbki kolorów z {file}|Zaimportowano {n} próbek kolorów z {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Zaimportowano {n} pędzel z {group}|Zaimportowano {n} pędzle z {group}|Zaimportowano {n} pędzli z {group} +@plural Played {n} step|Played {n} steps Odtworzono {n} krok|Odtworzono {n} kroki|Odtworzono {n} kroków +@plural {n} step|{n} steps {n} krok|{n} kroki|{n} kroków +@plural Saved for Web: {n} file|Saved for Web: {n} files Zapisano dla Internetu: {n} plik|Zapisano dla Internetu: {n} pliki|Zapisano dla Internetu: {n} plików +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Zaktualizowano {n} połączony obiekt inteligentny zmieniony na dysku|Zaktualizowano {n} połączone obiekty inteligentne zmienione na dysku|Zaktualizowano {n} połączonych obiektów inteligentnych zmienionych na dysku +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Usunięto {n} warstwę zapisaną w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstwy zapisane w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie.|Usunięto {n} warstw zapisanych w tej kompozycji. Zaktualizuj kompozycję lub usuń ostrzeżenie. +@plural {n} plane|{n} planes {n} płaszczyzna|{n} płaszczyzny|{n} płaszczyzn +@plural {n} preset|{n} presets {n} ustawienie wstępne|{n} ustawienia wstępne|{n} ustawień wstępnych +@plural {n} sample point removed|{n} sample points removed Usunięto {n} punkt próbkowania|Usunięto {n} punkty próbkowania|Usunięto {n} punktów próbkowania +@plural {n} slice|{n} slices {n} plasterek|{n} plasterki|{n} plasterków +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} kolejne ostrzeżenie dotyczące ustawień wstępnych pędzli)|{warning} (+{n} kolejne ostrzeżenia dotyczące ustawień wstępnych pędzli)|{warning} (+{n} kolejnych ostrzeżeń dotyczących ustawień wstępnych pędzli) +@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} ostrzeżenie|{what}: {n} ostrzeżenia|{what}: {n} ostrzeżeń + @plural {n} item|{n} items {n} element|{n} elementy|{n} elementów Auto Automatycznie @@ -1092,7 +1113,7 @@ Done Gotowe Dots Kropki Drag a rectangle around the object Przeciągnij prostokąt wokół obiektu - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Przeciągnij po obrazie, aby dodać ograniczenie; Shift — poziomo/pionowo; prawy przycisk usuwa. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Przeciągnij po obrazie, aby dodać ograniczenie; Shift — poziomo/pionowo; prawy przycisk na ograniczeniu — zmiana lub usunięcie. Drag to move the active shape's path or the Work Path Przeciągnij, aby przesunąć ścieżkę aktywnego kształtu lub ścieżkę roboczą Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: zaznacz punkt kontrolny · Przeciągnięcie: przesuń punkt, uchwyt lub segment · {shift}-klik: dodaj · {alt}-klik: cała podścieżka Draw with the Pen tool (P) or make a work path from a selection. Rysuj narzędziem Pióro (P) lub utwórz ścieżkę roboczą z zaznaczenia. @@ -1330,6 +1351,8 @@ Override Pressure Zastąp nacisk Override Rotation Zastąp obrót Override Tilt Zastąp nachylenie + Override Tilt X Zastąp nachylenie X + Override Tilt Y Zastąp nachylenie Y PRESET DETAILS SZCZEGÓŁY USTAWIENIA WSTĘPNEGO PSD PSD Paint Overlay Nakładka malowania @@ -1481,6 +1504,7 @@ Set shape fill type Ustaw typ wypełnienia kształtu Set shape stroke type Ustaw typ obrysu kształtu Set the text color Ustaw kolor tekstu + Create warped text Utwórz wypaczony tekst Shadows Cienie Shape: Kształt: Sharpening Wyostrzanie @@ -1984,6 +2008,7 @@ Quick export format Format szybkiego eksportu Quick export location Lokalizacja szybkiego eksportu Recent file count Liczba ostatnich plików + LUT folder Folder LUT Recent files Ostatnie pliki Recent fonts Ostatnie czcionki Recover on launch Odzyskaj przy uruchomieniu @@ -2394,6 +2419,7 @@ Clipping Path Ścieżka przycinająca New Path Nowa ścieżka Duplicate Path Duplikuj ścieżkę + Rename Path… Zmień nazwę ścieżki… Delete Path Usuń ścieżkę Auto-Hide Ukrywanie automatyczne W Sz. @@ -2459,6 +2485,8 @@ Keywords Słowa kluczowe Copyright Info URL Adres URL informacji o prawach autorskich Copyright Status Status praw autorskich + Copyright Notice Informacja o prawach autorskich + Document Title Tytuł dokumentu Copyrighted Chronione prawem autorskim Public Domain Domena publiczna Unknown Nieznany @@ -2678,6 +2706,8 @@ GPU acceleration could not continue. Your documents are unchanged. Akceleracja GPU nie mogła być kontynuowana. Twoje dokumenty nie zostały zmienione. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft używa kompozytora obrazu CPU. Okno może nadal korzystać z karty graficznej. Retry GPU requires a restart. Save your work first. Ponowna próba z GPU wymaga restartu. Najpierw zapisz swoją pracę. + Retry GPU tries the graphics processor again now. Spróbuj ponownie z GPU od razu ponawia próbę z procesorem graficznym. + GPU acceleration resumed. Akceleracja GPU wznowiona. Keep Using CPU Nadal używaj CPU Retry GPU Spróbuj ponownie z GPU GPU Backend Backend GPU @@ -2750,6 +2780,11 @@ cameraRaw Roughness Chropowatość Content-Aware Move Tool Narzędzie Przesunięcie z uwzględnieniem zawartości TIFF Options Opcje TIFF Targa Options Opcje Targa + JPEG Options Opcje JPEG + Image Options Opcje obrazu + Format Options Opcje formatu + Baseline ("Standard") Linia bazowa („Standardowa”) + Baseline Optimized Linia bazowa zoptymalizowana 24 bits/pixel 24 bity/piksel 32 bits/pixel 32 bity/piksel Including layers will increase file size. Dołączenie warstw zwiększy rozmiar pliku. @@ -3669,6 +3704,32 @@ cameraData Lens Obiektyw Mirror Odbicie lustrzane Output Settings Ustawienia wyjścia Output To Wyjście do + + Animation Options Opcje animacji + Entire Document Cały dokument + Filter Options Opcje filtrowania + Layer Bounds Granice warstwy + Layers Panel Options Opcje panelu Warstwy + New Artboard… Nowy obszar roboczy… + New Group… Nowa grupa… + Prevent Auto-Nesting Zapobiegaj automatycznemu zagnieżdżaniu + Show Layers Pokaż warstwy + Thumbnail Contents Zawartość miniaturek + Thumbnail Size Rozmiar miniaturek + Use Default Masks on Fill Layers Użyj domyślnych masek na warstwach wypełnienia + Expand New Effects Rozwijaj nowe efekty + Add “copy” to Copied Layers and Groups Dodawaj „kopia” do skopiowanych warstw i grup + Preview on canvas when reordering layers Podgląd na obszarze roboczym podczas zmiany kolejności warstw + Show layer mask badges Pokaż odznaki masek warstw + +# Smart Filter editors + Blending Options ({filter}) Opcje mieszania ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ukryj efekty warstwy i inteligentne filtry (kliknięcie z {key}: wszystkie warstwy) + Show the layer's effects and Smart Filters ({key}-click: all layers) Pokaż efekty warstwy i inteligentne filtry (kliknięcie z {key}: wszystkie warstwy) + Wait for the current job to finish. Poczekaj na zakończenie bieżącego zadania. + The document or Smart Filter changed. Reopen its dialog. Dokument lub inteligentny filtr został zmieniony. Otwórz ponownie jego okno dialogowe. + Opacity must be between 0% and 100%. Krycie musi wynosić od 0% do 100%. + Dissolve is approximated: it blends without Photoshop's dither. Rozpuszczanie jest przybliżone: miesza się bez ditheringu Photoshopa. Loading fonts… Wczytywanie czcionek… Response curve Krzywa odpowiedzi Simulate Pressure Symuluj nacisk @@ -3712,6 +3773,10 @@ curves Smooth Wygładź Click to place · Alt-click or drag off-canvas to remove Kliknij, aby umieścić · Alt-kliknij lub przeciągnij poza obszar roboczy, aby usunąć No document open Brak otwartego dokumentu {n} of {max} sample points {n} z {max} punktów próbkowania + +# History panel menu (#2966) + New Snapshot… Nowa migawka… + History Options… Opcje historii… # Print dialog (#2697) Emulsion Down Emulsja w dół Functions Funkcje @@ -3731,9 +3796,16 @@ curves Smooth Wygładź Light Gray Jasnoszary Object Finder Wyszukiwanie obiektów No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktywna warstwa nie ma tu pikseli — włącz Próbkuj wszystkie warstwy albo wybierz warstwę z pikselami Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Load Contours… Wczytaj kontury… + Replace Contours… Zamień kontury… + Save Contours… Zapisz kontury… + Delete Contour Usuń kontur + Loaded contours from {file} Wczytano kontury z {file} + Saved contours to {file} Zapisano kontury w {file} Show home recent files Pokaż ostatnie pliki na ekranie głównym Clear Brush Controls Wyczyść elementy sterujące pędzlem Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia @@ -3742,6 +3814,9 @@ curves Smooth Wygładź # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Wczytaj operacje… Save Actions… Zapisz operacje… + New Action… Nowa operacja… + Start Recording Rozpocznij nagrywanie + Stop Recording Zatrzymaj nagrywanie {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Wczytano {n} operację z {file}|Wczytano {n} operacje z {file}|Wczytano {n} operacji z {file} @@ -3773,6 +3848,16 @@ curves Smooth Wygładź Sent to {printer} Wysłano do {printer} set in Print Settings… ustawiane w ustawieniach druku… ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli +# History panel › History Brush source (#2966) + Paints from the History panel's source state Maluje ze stanu źródłowego panelu Historia + Sets the source for the history brush Ustawia źródło dla pędzla historii +# Bevel & Emboss shading controls. + Highlight Mode Tryb świateł + Highlight Color Kolor świateł + Highlight Opacity Krycie świateł + Shadow Mode Tryb cieni + Shadow Color Kolor cieni + Shadow Opacity Krycie cieni # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Użyj osadzonego profilu ({profile}) Convert to the working space ({profile}) Konwertuj na przestrzeń roboczą ({profile}) @@ -3789,10 +3874,14 @@ curves Smooth Wygładź Chisel Hard Dłuto twarde Chisel Soft Dłuto miękkie Open color-less EXR as ACEScg Otwieraj EXR bez profilu koloru jako ACEScg + Do not append "copy" to filename when saving a copy Nie dodawaj „copy” do nazwy pliku przy zapisywaniu kopii # View › Guides › right-click a guide Edit Selected Guides Edytuj zaznaczone prowadnice Clear Selected Guides Usuń zaznaczone prowadnice + Bicubic Automatic Dwusześcienna automatyczna + Bicubic Smoother Dwusześcienna wygładzona + Bicubic Sharper Dwusześcienna wyostrzona Pasting from other apps is unavailable Wklejanie z innych aplikacji jest niedostępne PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft nie ma w tej sesji dostępu do schowka systemowego. W Waylandzie piaskownica Flatpaka ukrywa protokół, przez który odczytywane są obrazy z innych aplikacji. To paste from other apps, start PhotoCraft under XWayland: `{command}` Aby wklejać z innych aplikacji, uruchom PhotoCraft pod XWaylandem: `{command}` @@ -3812,6 +3901,17 @@ curves Smooth Wygładź set name nazwa zestawu {set} (modified) {set} (zmieniony) Show file name in title Pokaż nazwę pliku w tytule + +# Image › Mode › Bitmap (#5559) + 50% Threshold Próg 50% + Pattern Dither Rozpraszanie wzorkiem + Diffusion Dither Rozpraszanie dyfuzyjne + Halftone Screen Raster półtonowy + Custom Pattern Własny wzór + Frequency: Częstotliwość: + Lines/Inch Linii/cal + Lines/Centimeter Linii/centymetr + Ellipse Elipsa Accept and Go to Conflict Akceptuj i przejdź do konfliktu Changes undone. Zmiany cofnięte. Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu. @@ -3829,9 +3929,11 @@ curves Smooth Wygładź GPU Memory Full Pamięć GPU pełna The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Warstwy potrzebują {needed} pamięci GPU i mogą zająć {budget}, więc edycje, które odświeżają cały dokument, robią to na CPU. Kliknij, aby ustawić Pamięć GPU. GPU memory budget: {budget} Budżet pamięci GPU: {budget} + Graphics memory: {size} Pamięć graficzna: {size} This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) + {path}: the path is not valid Unicode; save under another name. {path}: ścieżka nie jest prawidłowym Unicode; zapisz pod inną nazwą. Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. @@ -3839,6 +3941,22 @@ curves Smooth Wygładź Increase Brush Size Zwiększ rozmiar pędzla Decrease Brush Hardness Zmniejsz twardość pędzla Increase Brush Hardness Zwiększ twardość pędzla + Demosaic Demozaikowanie + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Sposób uzupełniania mozaiki filtra barwnego aparatu. Stosowany przy otwieraniu pliku; podgląd pokazuje metodę, z którą plik został otwarty. + AHD (adaptive homogeneity) AHD (adaptacyjna jednorodność) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: najczystsze krawędzie i najmniej barwnych obwódek, najwolniejsza. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolacja z korekcją gradientu: ostra i szybka, z niewielkimi barwnymi obwódkami na drobnych szczegółach. + The fastest and the softest, with colour fringing on fine detail. Najszybsza i najmiększa, z barwnymi obwódkami na drobnych szczegółach. + “{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić? + The name in the save dialog was “{name}”. Nazwa w oknie zapisywania: „{name}”. + Move Up Przesuń w górę + Move Down Przesuń w dół + Previous Brush Poprzedni pędzel + Next Brush Następny pędzel + First Brush Pierwszy pędzel + Last Brush Ostatni pędzel + Preview size Preview size Memory Usage Użycie pamięci Per-document RAM limit (MB) Limit RAM na dokument (MB) Installed RAM: {ram} MB; this limit: {percent}% Zainstalowany RAM: {ram} MB; ten limit: {percent}% @@ -3854,5 +3972,28 @@ curves Smooth Wygładź Compression Kompresja Generate Mipmaps Generuj mipmapy +# Color Table (.act) dialog + There is no color table to save. Brak tabeli kolorów do zapisania. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Zapisano {n} kolor do {file}|Zapisano {n} kolory do {file}|Zapisano {n} kolorów do {file} + # Graphics Adapter preference (#1885) GPU adapter Karta GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Najpierw zatwierdź lub anuluj otwarte okno kroku operacji + Pro Medium Gray Pro (średni szary) + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro (jasnoszary) + Pro Light Pro (jasny) + Resize Windows to Fit Dopasuj rozmiar okien + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatyczny kierunek akapitu + Left-to-right paragraph direction Kierunek akapitu od lewej do prawej + Right-to-left paragraph direction Kierunek akapitu od prawej do lewej + Not rendered: this adjustment ({key}) is kept as imported. Nie jest renderowana: ta korekta ({key}) jest zachowana w postaci zaimportowanej. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b21564e683..67964ad894 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -15,6 +15,10 @@ # entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) # stay as they are; the trailing "…" of dialog commands is kept. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opções de máscara… + Layer Mask Display Options… Opções de exibição de máscara de camada… + # Channel Options and New Spot Channel (#3761) Channel Color Cor do canal Color Indicates: A cor indica: @@ -1075,7 +1079,7 @@ Done Concluído Dots Pontos Drag a rectangle around the object Arraste um retângulo ao redor do objeto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito para excluir. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Arraste sobre a imagem para adicionar uma restrição; Shift para horizontal/vertical; clique direito em uma restrição para alterá-la ou excluí-la. Drag to move the active shape's path or the Work Path Arraste para mover o demarcador da forma ativa ou o demarcador de trabalho Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clique: selecionar ponto · Arrastar: mover ponto, alça ou segmento · {shift}-clique: adicionar · {alt}-clique: subdemarcador inteiro Draw with the Pen tool (P) or make a work path from a selection. Desenhe com a ferramenta Caneta (P) ou crie um demarcador de trabalho a partir de uma seleção. @@ -1311,6 +1315,8 @@ Override Pressure Substituir pressão Override Rotation Substituir rotação Override Tilt Substituir inclinação + Override Tilt X Substituir inclinação X + Override Tilt Y Substituir inclinação Y PRESET DETAILS DETALHES DA PREDEFINIÇÃO PSD PSD Paint Overlay Sobreposição de pintura @@ -1463,6 +1469,7 @@ Set shape fill type Definir tipo de preenchimento da forma Set shape stroke type Definir tipo de traçado da forma Set the text color Definir a cor do texto + Create warped text Criar texto deformado Shadows Sombras Shape: Forma: Sharpening Nitidez @@ -1958,6 +1965,7 @@ Quick export format Formato da exportação rápida Quick export location Local da exportação rápida Recent file count Número de arquivos recentes + LUT folder Pasta de LUTs Recent files Arquivos recentes Recent fonts Fontes recentes Recover on launch Recuperar ao iniciar @@ -2366,6 +2374,7 @@ Clipping Path Demarcador de corte New Path Novo demarcador Duplicate Path Duplicar demarcador + Rename Path… Renomear demarcador… Delete Path Excluir demarcador Auto-Hide Ocultar automaticamente W L @@ -2431,6 +2440,8 @@ Keywords Palavras-chave Copyright Info URL URL das informações de direitos autorais Copyright Status Status dos direitos autorais + Copyright Notice Aviso de copyright + Document Title Título do documento Copyrighted Protegido por direitos autorais Public Domain Domínio público Unknown Desconhecido @@ -2648,6 +2659,7 @@ 32 bit (float) 32 bits (flutuante) Floating point Ponto flutuante Use Global Light Usar luz global + Overprint Superimposição Native file drag-and-drop is unavailable Arrastar e soltar arquivos não está disponível Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. O recurso nativo de arrastar e soltar arquivos ainda não funciona no Wayland. Use Arquivo › Abrir ou copie a imagem no gerenciador de arquivos e cole-a com {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Para soltar arquivos, execute o PhotoCraft no XWayland: `{command}` @@ -2669,6 +2681,8 @@ GPU acceleration could not continue. Your documents are unchanged. A aceleração por GPU não pôde continuar. Seus documentos não foram alterados. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. O PhotoCraft está usando o compositor de imagens da CPU. A janela ainda pode usar seu adaptador gráfico. Retry GPU requires a restart. Save your work first. Tentar a GPU novamente exige reiniciar. Salve seu trabalho primeiro. + Retry GPU tries the graphics processor again now. Tentar a GPU novamente tenta o processador gráfico de novo agora. + GPU acceleration resumed. Aceleração por GPU retomada. Keep Using CPU Continuar usando a CPU Retry GPU Tentar a GPU novamente GPU Backend Backend da GPU @@ -2733,6 +2747,11 @@ cameraRaw Whites Brancos Content-Aware Move Tool Ferramenta Mover sensível ao conteúdo TIFF Options Opções de TIFF Targa Options Opções de Targa + JPEG Options Opções de JPEG + Image Options Opções de imagem + Format Options Opções de formato + Baseline ("Standard") Linha de base ("Padrão") + Baseline Optimized Linha de base otimizada 24 bits/pixel 24 bits/pixel 32 bits/pixel 32 bits/pixel Including layers will increase file size. Incluir as camadas aumentará o tamanho do arquivo. @@ -3078,6 +3097,32 @@ cameraData Lens Lente New Layer Nova camada Duplicate Layer Duplicar camada Contract Contrair + + Animation Options Opções de animação + Entire Document Documento inteiro + Filter Options Opções de filtro + Layer Bounds Limites da camada + Layers Panel Options Opções do painel Camadas + New Artboard… Nova prancheta… + New Group… Novo grupo… + Prevent Auto-Nesting Impedir aninhamento automático + Show Layers Mostrar camadas + Thumbnail Contents Conteúdo das miniaturas + Thumbnail Size Tamanho das miniaturas + Use Default Masks on Fill Layers Usar máscaras padrão nas camadas de preenchimento + Expand New Effects Expandir novos efeitos + Add “copy” to Copied Layers and Groups Adicionar “cópia” às camadas e grupos copiados + Preview on canvas when reordering layers Pré-visualizar na tela ao reordenar camadas + Show layer mask badges Mostrar emblemas de máscaras de camada + +# Smart Filter editors + Blending Options ({filter}) Opções de mesclagem ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ocultar efeitos da camada e Filtros Inteligentes ({key}-clique: todas as camadas) + Show the layer's effects and Smart Filters ({key}-click: all layers) Mostrar efeitos da camada e Filtros Inteligentes ({key}-clique: todas as camadas) + Wait for the current job to finish. Aguarde a conclusão da tarefa atual. + The document or Smart Filter changed. Reopen its dialog. O documento ou o Filtro Inteligente mudou. Reabra sua caixa de diálogo. + Opacity must be between 0% and 100%. A opacidade deve estar entre 0% e 100%. + Dissolve is approximated: it blends without Photoshop's dither. Dissolver é aproximado: a mesclagem ocorre sem o dither do Photoshop. Loading fonts… Carregando fontes… Response curve Curva de resposta Tool Ferramenta @@ -3889,6 +3934,10 @@ layerLabel No Color Sem cor Set Colors Definir cores Switch Foreground and Background Colors Alternar cores de primeiro plano e plano de fundo Target Alvo + +# History panel menu (#2966) + New Snapshot… Novo instantâneo… + History Options… Opções de histórico… # Print dialog (#2697) Emulsion Down Emulsão para baixo Functions Funções @@ -3923,9 +3972,16 @@ layerLabel No Color Sem cor Light Gray Cinza-claro Object Finder Localizador de objetos No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels A camada ativa não tem pixels aqui — ative Usar amostra de todas as camadas ou selecione uma camada com pixels Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Load Contours… Carregar contornos… + Replace Contours… Substituir contornos… + Save Contours… Salvar contornos… + Delete Contour Excluir contorno + Loaded contours from {file} Contornos carregados de {file} + Saved contours to {file} Contornos salvos em {file} Show home recent files Mostrar arquivos recentes na tela inicial Clear Brush Controls Limpar controles de pincel Reset All Locked Settings Redefinir todas as configurações bloqueadas @@ -3934,6 +3990,9 @@ layerLabel No Color Sem cor # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carregar ações… Save Actions… Salvar ações… + New Action… Nova ação… + Start Recording Iniciar gravação + Stop Recording Parar gravação {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} ação carregada de {file}|{n} ações carregadas de {file} @@ -3965,6 +4024,16 @@ layerLabel No Color Sem cor Sent to {printer} Enviado para {printer} set in Print Settings… definido nas configurações de impressão… ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels +# History panel › History Brush source (#2966) + Paints from the History panel's source state Pinta a partir do estado de origem do painel Histórico + Sets the source for the history brush Define a origem do pincel do histórico +# Bevel & Emboss shading controls. + Highlight Mode Modo de realces + Highlight Color Cor dos realces + Highlight Opacity Opacidade dos realces + Shadow Mode Modo de sombras + Shadow Color Cor das sombras + Shadow Opacity Opacidade das sombras # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Usar o perfil incorporado ({profile}) Convert to the working space ({profile}) Converter para o espaço de trabalho ({profile}) @@ -3981,10 +4050,14 @@ layerLabel No Color Sem cor Chisel Hard Cinzel duro Chisel Soft Cinzel suave Open color-less EXR as ACEScg Abrir EXR sem espaço de cor como ACEScg + Do not append "copy" to filename when saving a copy Não acrescentar "copy" ao nome do arquivo ao salvar uma cópia # View › Guides › right-click a guide Edit Selected Guides Editar guias selecionadas Clear Selected Guides Limpar guias selecionadas + Bicubic Automatic Bicúbica automática + Bicubic Smoother Bicúbica mais suave + Bicubic Sharper Bicúbica mais nítida Pasting from other apps is unavailable Colar de outros aplicativos não está disponível PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. O PhotoCraft não consegue acessar a área de transferência do sistema nesta sessão. No Wayland, a sandbox do Flatpak oculta o protocolo pelo qual as imagens de outros aplicativos são lidas. To paste from other apps, start PhotoCraft under XWayland: `{command}` Para colar de outros aplicativos, execute o PhotoCraft no XWayland: `{command}` @@ -4004,6 +4077,17 @@ layerLabel No Color Sem cor set name nome do conjunto {set} (modified) {set} (modificado) Show file name in title Exibir nome do arquivo no título + +# Image › Mode › Bitmap (#5559) + 50% Threshold Limiar de 50% + Pattern Dither Pontilhamento de padrão + Diffusion Dither Pontilhamento de difusão + Halftone Screen Retícula de meio-tom + Custom Pattern Padrão personalizado + Frequency: Frequência: + Lines/Inch Linhas/polegada + Lines/Centimeter Linhas/centímetro + Ellipse Elipse Accept and Go to Conflict Aceitar e ir para o conflito Changes undone. Alterações desfeitas. Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho. @@ -4021,15 +4105,33 @@ layerLabel No Color Sem cor GPU Memory Full Memória da GPU cheia The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. As camadas precisam de {needed} de memória da GPU e podem ocupar {budget}, então as edições que redesenham o documento inteiro fazem isso na CPU. Clique para ajustar Memória da GPU. GPU memory budget: {budget} Orçamento de memória da GPU: {budget} + Graphics memory: {size} Memória gráfica: {size} This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) + {path}: the path is not valid Unicode; save under another name. {path}: o caminho não é Unicode válido; salve com outro nome. {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. Decrease Brush Size Diminuir tamanho do pincel Increase Brush Size Aumentar tamanho do pincel Decrease Brush Hardness Diminuir dureza do pincel Increase Brush Hardness Aumentar dureza do pincel + Demosaic Demosaico + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Como o mosaico do filtro de cor da câmera é preenchido. É aplicado ao abrir o arquivo; a visualização mostra o método com que ele foi aberto. + AHD (adaptive homogeneity) AHD (homogeneidade adaptativa) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: as bordas mais limpas e o menor franjamento de cor; o mais lento. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolação com correção de gradiente: nítida e rápida, com algum franjamento de cor em detalhes finos. + The fastest and the softest, with colour fringing on fine detail. O mais rápido e o mais suave, com franjamento de cor em detalhes finos. + “{name}” already exists. Replace it? “{name}” já existe. Substituir? + The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”. + Move Up Mover para cima + Move Down Mover para baixo + Previous Brush Pincel anterior + Next Brush Próximo pincel + First Brush Primeiro pincel + Last Brush Último pincel + Preview size Preview size Memory Usage Uso de memória Per-document RAM limit (MB) Limite de RAM por documento (MB) Installed RAM: {ram} MB; this limit: {percent}% RAM instalada: {ram} MB; este limite: {percent}% @@ -4044,5 +4146,177 @@ layerLabel No Color Sem cor DDS Options Opções de DDS Generate Mipmaps Gerar mipmaps +# Color Table (.act) dialog + There is no color table to save. Não há tabela de cores para salvar. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} cor salva em {file}|{n} cores salvas em {file} + # Graphics Adapter preference (#1885) GPU adapter Adaptador de GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Confirme ou cancele primeiro a caixa de diálogo aberta da etapa da ação + +# Channels panel menu: channel commands, spot channels, Quick Mask, Color Indicates + New Channel… Novo canal… + Duplicate Channel Duplicar canal + Delete Channel Excluir canal + Rename Channel… Renomear canal… + New Spot Channel… Novo canal de cor especial… + Merge Spot Channel Mesclar canal de cor especial + Convert to Alpha Channel Converter em canal alfa + Split Channels Dividir canais + Merge Channels… Mesclar canais… + Exit Quick Mask Sair da máscara rápida + Color Indicates Masked Areas A cor indica áreas mascaradas + Color Indicates Selected Areas A cor indica áreas selecionadas + +# Layer Style › Blending Options: advanced blending and Blend If + General Blending Mesclagem geral + Shallow Raso + Deep Profundo + Blend Interior Effects as Group Mesclar efeitos internos como grupo + Blend Clipped Layers as Group Mesclar camadas recortadas como grupo + Transparency Shapes Layer A transparência define a forma da camada + Layer Mask Hides Effects A máscara de camada oculta os efeitos + Vector Mask Hides Effects A máscara vetorial oculta os efeitos + This Layer: Esta camada: + Underlying Layer: Camada subjacente: + +# Canvas right-click menus: pasteboard color, paths and shapes + Dark Gray Cinza-escuro + Medium Gray Cinza médio + Select Custom Color… Selecionar cor personalizada… + Color Picker (Custom Canvas Color) Seletor de cores (Cor personalizada da tela de pintura) + Make Work Path… Criar demarcador de trabalho… + Create Vector Mask Criar máscara vetorial + Make Selection… Fazer seleção… + Fill Path… Preencher demarcador… + Clipping Path… Demarcador de corte… + Free Transform Path Transformação livre do demarcador + Unite Shapes at Overlap Unir formas na sobreposição + Subtract Shapes at Overlap Subtrair formas na sobreposição + Copy Fill Copiar preenchimento + Copy Complete Stroke Copiar traçado completo + Paste Fill Colar preenchimento + Paste Complete Stroke Colar traçado completo + Make Symmetry Path Criar demarcador de simetria + Disable Symmetry Path Desativar demarcador de simetria + +# Color Picker titles + Color Picker (Foreground Color) Seletor de cores (Cor de primeiro plano) + Color Picker (Background Color) Seletor de cores (Cor de fundo) + Pick Color From Image Escolher cor da imagem + +# Rasterize prompts + This layer must be rasterized before proceeding. Esta camada precisa ser rasterizada antes de continuar. + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Esta camada de texto precisa ser rasterizada antes de continuar. O texto não poderá mais ser editado. Rasterizar o texto? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Esta camada de forma precisa ser rasterizada antes de continuar. Ela não poderá mais ser editada como forma. Rasterizar a forma? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Este objeto inteligente precisa ser rasterizado antes de continuar. O conteúdo não poderá mais ser editado. Rasterizar o objeto inteligente? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Esta camada de preenchimento precisa ser rasterizada antes de continuar. O conteúdo do preenchimento não poderá mais ser editado. Rasterizar a camada? + +# GPU and display status (status bar, System Info) + Graphics adapter: {adapter} Adaptador gráfico: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Driver: {driver} + GPU backend preference: {backend} Preferência de backend da GPU: {backend} + Selected at launch: {backend} Selecionado ao iniciar: {backend} + Image compositor: {processor} Compositor de imagens: {processor} + Window renderer: {renderer} Renderizador da janela: {renderer} + Graphics adapter Adaptador gráfico + Software adapter Adaptador de software + unknown type tipo desconhecido + Fallback: {reason} Modo alternativo: {reason} + This session: {reason} Nesta sessão: {reason} + Platform: {os} {arch} Plataforma: {os} {arch} + GPU retry scheduled Nova tentativa da GPU agendada + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Salve seu trabalho e reinicie o PhotoCraft para tentar a aceleração por GPU novamente. A renderização pela CPU continua ativa nesta sessão. + GPU device was lost; using the CPU renderer. O dispositivo da GPU foi perdido; usando o renderizador da CPU. + GPU error; using the CPU renderer. Erro da GPU; usando o renderizador da CPU. + Monitor profile: {profile} Perfil do monitor: {profile} + Monitor profile in use: {profile} Perfil do monitor em uso: {profile} + Monitor profile in use on {display}: {profile} Perfil do monitor em uso em {display}: {profile} + Monitor profile on {display}: {profile} Perfil do monitor em {display}: {profile} + Monitor profile on {display} (main window): {profile} Perfil do monitor em {display} (janela principal): {profile} + Display profiles: {reason} Perfis de tela: {reason} + Display profile not used Perfil de tela não usado + previous reading kept; the last re-read failed: {reason} leitura anterior mantida; a última releitura falhou: {reason} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A tela de pintura é exibida como sRGB. Escolha um perfil de monitor em Editar › Configurações de cores. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A tela de pintura em {display} é exibida como sRGB. Escolha um perfil de monitor em Editar › Configurações de cores. + +# Status messages + Nothing to paste: the clipboard holds no image Nada para colar: a área de transferência não contém imagem + Opened {url} Aberto: {url} + Preferences saved Preferências salvas + Preferences were reset: {error} As preferências foram redefinidas: {error} + Couldn't save preferences: {error}. Retrying… Não foi possível salvar as preferências: {error}. Tentando novamente… + Preferences can't be saved Não é possível salvar as preferências + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. O PhotoCraft continua tentando; até que um salvamento dê certo, as alterações nas preferências serão perdidas ao fechar. + Invalid panel layout; restored the default dock Layout de painéis inválido; a disposição padrão dos painéis foi restaurada + Recovered {name} Recuperado: {name} + Autosave failed: {error} Falha ao salvar automaticamente: {error} + Exported presets to {path} Predefinições exportadas para {path} + Ran script {name} Script executado: {name} + Sent to the printer ({spooler}) Enviado para a impressora ({spooler}) + Printed to {path} Impresso em {path} + Exported {path} ({size}) Exportado: {path} ({size}) + Exported {path} Exportado: {path} + Saved a copy as {path} Cópia salva como {path} + Exported contents to {path} Conteúdo exportado para {path} + Saved {path}; image assets: {n} in {dir} Salvo: {path}; recursos de imagem: {n} em {dir} + Couldn't open {name}: {error} Não foi possível abrir {name}: {error} + Added swatch {name} Amostra adicionada: {name} + {message} (notes: {n}; {warning}) {message} (observações: {n}; {warning}) + Step {step} ({id}) failed: {error} Falha na etapa {step} ({id}): {error} + Could not rename the action: {error} Não foi possível renomear a ação: {error} + Action playback failed: {error} Falha ao reproduzir a ação: {error} + Clone source set at {x}, {y} Origem da clonagem definida em {x}, {y} + Color management: {error} Gerenciamento de cores: {error} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nenhuma forma personalizada “{name}”: escolha uma na barra de opções ou em Janela › Formas + {label} is not available: {reason} {label} não está disponível: {reason} + Open Recent is unavailable on the web Abrir recentes não está disponível na web + Importing {what} from {file}… Importando {what} de {file}… + brushes pincéis + gradients degradês + This adjustment ({key}) is kept as imported and can't be edited yet. Este ajuste ({key}) é mantido como foi importado e ainda não pode ser editado. + {shortcut} assigned. {shortcut} atribuído. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} já está em uso por {commands} e será removido de lá quando você clicar em OK. + +# Panels and small controls + Grid view Visualização em grade + List view Visualização em lista + New Brush Preset Nova predefinição de pincel + Toggle the Brush Settings panel Alternar o painel Configurações do pincel + Scroll menu up Rolar o menu para cima + Scroll menu down Rolar o menu para baixo + {index} of {total} {index} de {total} + Data Set: {name} ({index}/{total}) Conjunto de dados: {name} ({index}/{total}) + (none) (nenhum) + Apply {option}: {state} Aplicar {option}: {state} + on ativado + off desativado + +# Liquify tools (tooltips) + Forward Warp Deformação para frente + Twirl Clockwise Torcer no sentido horário + Twirl Counterclockwise Torcer no sentido anti-horário + Pucker Franzir + Bloat Inflar + Push Left Empurrar para a esquerda + Freeze Mask Congelar máscara + Thaw Mask Descongelar máscara + Pro Medium Gray Pro cinza médio + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro cinza claro + Pro Light Pro claro + Resize Windows to Fit Redimensionar janelas para caber + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Direção automática do parágrafo + Left-to-right paragraph direction Direção do parágrafo da esquerda para a direita + Right-to-left paragraph direction Direção do parágrafo da direita para a esquerda + Not rendered: this adjustment ({key}) is kept as imported. Não renderizado: este ajuste ({key}) é mantido como foi importado. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ff6fc08de8..791dcf8fb8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -17,6 +17,10 @@ # letters; the trailing "…" of dialog commands is kept; the colon of a label ("Width:") becomes # the Russian typographic equivalent, with the space the source carries. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Параметры маски… + Layer Mask Display Options… Параметры отображения маски слоя… + # Channel Options and New Spot Channel (#3761) Channel Color Цвет канала Color Indicates: Цветом обозначены: @@ -247,6 +251,7 @@ Inner Glow… Внутреннее свечение… Bevel & Emboss… Тиснение… Use Global Light Использовать глобальный свет + Overprint Наложение Satin… Глянец… Color Overlay… Наложение цвета… Gradient Overlay… Наложение градиента… @@ -782,6 +787,7 @@ Set shape fill type Тип заливки фигуры Set shape stroke type Тип обводки фигуры Set the text color Цвет текста + Create warped text Создать деформированный текст Only Web Colors Только web-цвета Overlay Color Цвет наложения Overlay Opacity Непрозрачность наложения @@ -1066,6 +1072,8 @@ No brush presets Нет наборов кистей Override Pressure Переопределить нажим Override Tilt Переопределить наклон + Override Tilt X Переопределить наклон X + Override Tilt Y Переопределить наклон Y Override Rotation Переопределить поворот Apply the pen's tilt and rotation to the tip shape Применять наклон и поворот пера к форме отпечатка @@ -1086,6 +1094,7 @@ # Paragraph and character panels Font Size: Кегль: + Font size Кегль Type: Текст: Style Стиль Style: Normal Стиль: Обычный @@ -1195,6 +1204,7 @@ Clipping Path Обтравочный контур New Path Новый контур Duplicate Path Дублировать контур + Rename Path… Переименовать контур… Delete Path Удалить контур Current Path Текущий контур Vector Mask from Current Path Векторная маска из текущего контура @@ -1344,6 +1354,8 @@ Keywords Ключевые слова Copyright Info URL URL сведений об авторских правах Copyright Status Статус авторских прав + Copyright Notice Уведомление об авторских правах + Document Title Название документа Copyrighted Охраняется авторским правом Public Domain Общественное достояние Unknown Неизвестно @@ -1583,7 +1595,7 @@ Click to restore the current colour Щёлкните, чтобы восстановить текущий цвет Click with the Note tool to add a note. Щёлкните инструментом «Заметки», чтобы добавить заметку. Drag a rectangle around the object Обведите объект прямоугольником - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Перетащите по изображению, чтобы добавить ограничение; Shift — горизонталь/вертикаль; правый клик — удалить. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Перетащите по изображению, чтобы добавить ограничение; Shift — горизонталь/вертикаль; щелчок правой кнопкой по ограничению — изменить или удалить. Drag the arrow to set the angle, the dots to set the roundness Перетащите стрелку, чтобы задать угол, и точки — круглость Create new pattern from the selection Создать новый паттерн из выделения Create new gradient Создать новый градиент @@ -2419,6 +2431,7 @@ Quick export format Формат быстрого экспорта Quick export location Расположение быстрого экспорта Recent file count Число недавних файлов + LUT folder Папка LUT Recent files Недавние файлы Recent fonts Недавние шрифты Recover on launch Восстановление при запуске @@ -2516,6 +2529,8 @@ GPU acceleration could not continue. Your documents are unchanged. Ускорение GPU остановлено. Документы не изменены. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft обрабатывает изображения на CPU. Окно может продолжать использовать графический адаптер. Retry GPU requires a restart. Save your work first. Для повтора GPU нужен перезапуск. Сначала сохраните работу. + Retry GPU tries the graphics processor again now. Повторить GPU — сразу снова попробовать графический процессор. + GPU acceleration resumed. Ускорение GPU возобновлено. Keep Using CPU Продолжить с CPU Retry GPU Повторить GPU GPU Backend Бэкенд GPU @@ -2583,6 +2598,11 @@ cameraRaw Roughness Неровность Content-Aware Move Tool Перемещение с учётом содержимого TIFF Options Параметры TIFF Targa Options Параметры Targa + JPEG Options Параметры JPEG + Image Options Параметры изображения + Format Options Разновидность формата + Baseline ("Standard") Базовая («Стандартная») + Baseline Optimized Базовая оптимизированная 24 bits/pixel 24 бит/пиксел 32 bits/pixel 32 бит/пиксел Including layers will increase file size. Включение слоёв увеличит размер файла. @@ -2998,6 +3018,33 @@ cameraData Lens Объектив Output To Вывод в Duplicate Layer Дубликат слоя Contract Сжать + + Animation Options Параметры анимации + Entire Document Весь документ + Filter Options Параметры фильтра + Layer Bounds Границы слоя + Layers Panel Options Параметры панели «Слои» + Lock Layers Блокировка слоёв + New Artboard… Новая монтажная область… + New Group… Новая группа… + Prevent Auto-Nesting Запретить автоматическое вложение + Show Layers Показать слои + Thumbnail Contents Содержимое миниатюр + Thumbnail Size Размер миниатюр + Use Default Masks on Fill Layers Использовать маски по умолчанию для слоёв-заливок + Expand New Effects Раскрывать новые эффекты + Add “copy” to Copied Layers and Groups Добавлять «копия» к дублированным слоям и группам + Preview on canvas when reordering layers Предпросмотр на холсте при изменении порядка слоёв + Show layer mask badges Показывать значки типов масок слоя + +# Smart Filter editors + Blending Options ({filter}) Параметры наложения ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Скрыть эффекты слоя и смарт-фильтры ({key}-клик: все слои) + Show the layer's effects and Smart Filters ({key}-click: all layers) Показать эффекты слоя и смарт-фильтры ({key}-клик: все слои) + Wait for the current job to finish. Дождитесь завершения текущей задачи. + The document or Smart Filter changed. Reopen its dialog. Документ или смарт-фильтр изменился. Откройте его диалог заново. + Opacity must be between 0% and 100%. Непрозрачность должна быть от 0% до 100%. + Dissolve is approximated: it blends without Photoshop's dither. Растворение приближённое: смешивание без дизеринга Photoshop. Loading fonts… Загрузка шрифтов… Response curve Кривая отклика Tool Инструмент @@ -3049,6 +3096,10 @@ curves Smooth Сгладить Click to place · Alt-click or drag off-canvas to remove Щёлкните, чтобы разместить · Alt-щелчок или перетаскивание за пределы холста для удаления No document open Документ не открыт {n} of {max} sample points {n} из {max} точек замера + +# History panel menu (#2966) + New Snapshot… Новый снимок… + History Options… Параметры истории… # Print dialog (#2697) Emulsion Down Эмульсией вниз Functions Функции @@ -3083,9 +3134,16 @@ curves Smooth Сгладить Light Gray Светло-серый Object Finder Поиск объектов No object found there — drag a rectangle around it Объект не найден — обведите его прямоугольником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активном слое здесь нет пикселей — включите «По всем слоям» или выберите слой с пикселями Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Load Contours… Загрузить контуры… + Replace Contours… Заменить контуры… + Save Contours… Сохранить контуры… + Delete Contour Удалить контур + Loaded contours from {file} Контуры загружены из {file} + Saved contours to {file} Контуры сохранены в {file} Show home recent files Показывать недавние файлы на домашнем экране Clear Brush Controls Очистить параметры кисти Reset All Locked Settings Сбросить все заблокированные параметры @@ -3094,6 +3152,9 @@ curves Smooth Сгладить # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Загрузить операции… Save Actions… Сохранить операции… + New Action… Новая операция… + Start Recording Начать запись + Stop Recording Остановить запись {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Загружена {n} операция из {file}|Загружены {n} операции из {file}|Загружено {n} операций из {file} @@ -3126,6 +3187,9 @@ curves Smooth Сгладить Sent to {printer} Отправлено на {printer} set in Print Settings… задаётся в параметрах печати… ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей +# History panel › History Brush source (#2966) + Paints from the History panel's source state Рисует из исходного состояния панели «История» + Sets the source for the history brush Задаёт источник для кисти истории # Dynamic selection context menu and appearance favorites Select Inverse Инвертировать выделение @@ -3146,6 +3210,13 @@ curves Smooth Сгладить Gray Value (Median) Уровень серого (медиана) Integrated Density Интегральная плотность Length Длина +# Bevel & Emboss shading controls. + Highlight Mode Режим светлых тонов + Highlight Color Цвет светлых тонов + Highlight Opacity Непрозрачность светлых тонов + Shadow Mode Режим теней + Shadow Color Цвет теней + Shadow Opacity Непрозрачность теней # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Использовать встроенный профиль ({profile}) Convert to the working space ({profile}) Преобразовать в рабочее пространство ({profile}) @@ -3162,10 +3233,14 @@ curves Smooth Сгладить Chisel Hard Жесткая огранка Chisel Soft Мягкая огранка Open color-less EXR as ACEScg Открывать EXR без цветового профиля как ACEScg + Do not append "copy" to filename when saving a copy Не добавлять «copy» к имени файла при сохранении копии # View › Guides › right-click a guide Edit Selected Guides Изменить выбранные направляющие Clear Selected Guides Удалить выбранные направляющие + Bicubic Automatic Бикубическая, автоматически + Bicubic Smoother Бикубическая, глаже + Bicubic Sharper Бикубическая, чётче Pasting from other apps is unavailable Вставка из других приложений недоступна PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. В этом сеансе PhotoCraft не может получить доступ к системному буферу обмена. В Wayland песочница Flatpak скрывает протокол, через который читаются изображения из других приложений. To paste from other apps, start PhotoCraft under XWayland: `{command}` Чтобы вставлять из других приложений, запустите PhotoCraft под XWayland: `{command}` @@ -3186,6 +3261,19 @@ curves Smooth Сгладить {set} (modified) {set} (изменён) Random Случайно Show file name in title Показывать имя файла в заголовке + +# Image › Mode › Bitmap (#5559) + 50% Threshold Порог 50% + Pattern Dither Регулярный дизеринг + Diffusion Dither Диффузионный дизеринг + Halftone Screen Полутоновый растр + Custom Pattern Свой узор + Frequency: Линиатура: + Lines/Inch Линий/дюйм + Lines/Centimeter Линий/см + Ellipse Эллипс + Diamond Ромб + Cross Крест Accept and Go to Conflict Принять и перейти к конфликту Changes undone. Изменения отменены. Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации. @@ -3203,16 +3291,536 @@ curves Smooth Сгладить GPU Memory Full Память GPU заполнена The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Слоям нужно {needed} памяти GPU, а занимать они могут {budget}, поэтому правки, перерисовывающие весь документ, выполняются на CPU. Нажмите, чтобы задать «Память GPU». GPU memory budget: {budget} Бюджет памяти GPU: {budget} + Graphics memory: {size} Видеопамять: {size} This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) + {path}: the path is not valid Unicode; save under another name. {path}: путь не является допустимым Unicode; сохраните под другим именем. Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. - Decrease Brush Size Уменьшить размер кисти - Increase Brush Size Увеличить размер кисти + + +# Dynamic UI labels (fourth pass): filter and adjustment parameters, Camera Raw panels, +# lighting, History/Layers/paths/channels menus, metadata, System Info, status templates and +# dialog text that other catalogs already carry. Written from the meaning of the English labels +# in the ordinary Russian graphics-editor vocabulary; no proprietary resource consulted. + (coming soon) (скоро) + (none) (нет) + 32-bit Preview Options Параметры предпросмотра 32 бит + 3D… 3D… + A: Ш: + AI Integrations Интеграции ИИ + About {app} О программе {app} + Accented Edges Акцентированные края + Action playback failed: {error} Не удалось воспроизвести операцию: {error} + Actual Pixels Реальный размер + Add Mask Добавить маску + Add Noise Добавить шум + Add a mask (from the selection; ⌥ inverts) Добавить маску (из выделенной области; ⌥ инвертирует) + Add space after paragraph Добавить интервал после абзаца + Add space before paragraph Добавить интервал перед абзацем + Add to selection (⇧) Добавить к выделенной области (⇧) + Added swatch {name} Добавлен образец {name} + Adds grain to the soft edges of the brush tip. Добавляет зерно к мягким краям кисти. + Adjust Interval For Loops Настроить интервал циклов + Adjustment Layers Корректирующие слои + Adjustment Properties Свойства коррекции + Adjustment layers have nothing to transform В корректирующих слоях нечего трансформировать + Adobe PDF Presets… Наборы Adobe PDF… + Adobe-rgb-compat Adobe RGB (совместимо) + Adwaita (Dark) Adwaita (тёмная) + Adwaita (Light) Adwaita (светлая) + After После + Airbrush: keeps painting while the pointer rests. Аэрограф: продолжает рисовать, пока указатель неподвижен. + All shortcuts reset to Photoshop defaults. Все сочетания клавиш сброшены к значениям Photoshop по умолчанию. + Altitude Высота + Ambience Атмосфера + Ambient Окружающий свет + Amplitude Max Макс. амплитуда + Amplitude Min Мин. амплитуда + Android 1080p Android 1080p + Angled Strokes Наклонные штрихи + Anisotropic Анизотропный + Any Любой + Apple Watch 45mm Apple Watch 45 мм + Apply Data Set Применить набор данных + Apply Image Внешний канал + Apply Mask Применить маску + Apply to All Применить ко всем + Apply {option}: {state} Применить {option}: {state} + Arbitrary Произвольный + Arc Lower Нижняя дуга + Arc Upper Верхняя дуга + Are you sure you want to quit? Вы действительно хотите выйти? + Are you sure? Вы уверены? + Around Center Вокруг центра + Arrangement Расположение + Arrows Стрелки + Artboard from Group Монтажная область из группы + Artboard from Layers Монтажная область из слоёв + Artboards Монтажные области + Artboards to Files Монтажные области в файлы + Artboards to PDF Монтажные области в PDF + Artistic Художественные + Ask On Mismatch Спрашивать при несовпадении + Ask On Missing Спрашивать при отсутствии + Ask On Paste Спрашивать при вставке + Assign Profile Назначить профиль + At Позиция + Author Title Имя автора + Auto Scale Автомасштаб + Auto-Align Layers Автовыравнивание слоёв + Auto-Blend Layers Автосмешивание слоёв + Autosave failed: {error} Не удалось выполнить автосохранение: {error} + Back Назад + Backend: {backend} ({type}) Сервер: {backend} ({type}) + Background Level Уровень фона + Bamboo Бамбук + Bas Relief Барельеф + Base Tree Type Базовый тип дерева + Based On На основе + Baseline shift Смещение базовой линии + Basic Latin Основная латиница + Batch Пакетная обработка + Before До + Bend Изгиб + Berries Ягоды + Best Наилучшее + Black Clip Отсечение чёрного + Black Intensity Интенсивность чёрного + Blade Curvature Кривизна лепестков диафрагмы + Blast Порыв + Bleach Bypass Пропуск отбеливания + Blend Clipped Layers as Group Смешивать обтравочные слои как группу + Blend Interior Effects as Group Смешивать внутренние эффекты как группу + Blend Text Gamma Гамма смешивания текста + Blending Mode Режим наложения + Bloat Вздутие + Blocks Блоки + Blue Yellow Синий–жёлтый + Blues Синие тона + Border Граница + Border Thickness Толщина границы + Box Blur Размытие по рамке + Bpc Компенсация чёрной точки + Branches Color Цвет ветвей + Branches Height Высота ветвей + Branches Thickness Толщина ветвей + Breeze Dark Breeze (тёмная) + Breeze Light Breeze (светлая) + Brick Кирпич + Bristle Detail Детализация щетины + Browse Обзор + Brush Detail Детализация кисти + Brush Settings… (F5): every option of this brush Параметры кисти… (F5): все параметры этой кисти + Brush Strokes Мазки кисти + Brush Type Тип кисти + Burlap Мешковина + Calculations Вычисления + Camera Raw Camera Raw + Camera Raw Filter Фильтр Camera Raw + Cancel transform Отменить трансформирование + Candle Light Свет свечи + Canvas Size Размер холста + Case Sensitive Учитывать регистр + Ccw Против часовой стрелки + Cell Size Размер ячейки + Center X Центр X + Center Y Центр Y + Chalk Area Область мела + Change Find Найти и заменить + Changes Saved Изменения сохранены + Changes have not been saved. Изменения не сохранены. + Change… Изменить… + Channel1 Канал 1 + Channel2 Канал 2 + Channel3 Канал 3 + Channel4 Канал 4 + Charcoal Area Область угля + Charcoal Thickness Толщина угля + Check for Updates Проверить обновления + Checkerboard Шахматная доска + Choke Поджатие + Choose Folder Выбрать папку + Circle Круг + Cleanliness Чистота + Clear Amount Величина очистки + Clear Selection Отменить выделение + Click sets foreground · right-click sets background Щелчок задаёт цвет переднего плана · щелчок правой кнопкой задаёт цвет фона + Click the first point or press ↵ to close · Esc cancels Щёлкните первую точку или нажмите ↵, чтобы замкнуть · Esc отменяет + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Щёлкните, чтобы добавить точку · перетащите за пределы, нажмите ⌘/Ctrl и щёлкните или нажмите Delete, чтобы удалить + Click to apply, ⌥-click to clear overrides, double-click for Style Options Щёлкните, чтобы применить, ⌥+щелчок, чтобы очистить переопределения, двойной щелчок — параметры стиля + Click to zoom in · ⌥-click to zoom out Щёлкните, чтобы увеличить · ⌥+щелчок, чтобы уменьшить + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Щелчок: угол · Перетаскивание: сглаживание · Щелчок по первой точке: замкнуть · ↩ завершить · Esc отменить + Clipping Mask Обтравочная маска + Clipping Path… Контур отсечения… + Clone Клонировать + Clone source set at {x}, {y} Источник клонирования задан в {x}, {y} + Close Window Закрыть окно + Clumped Сгруппированные + Cmyk CMYK + Coarse Dots Крупные точки + Coated-cmyk Мелованная CMYK + Collapse group (⌥-click: all groups) Свернуть группу (⌥+щелчок: все группы) + Color Decontaminate Удаление цветового ореола + Color Halftone Цветные полутона + Color Indicates Masked Areas Цветом обозначены маскированные области + Color Indicates Selected Areas Цветом обозначены выделенные области + Color Lookup Tables Таблицы перекодировки цвета + Color Picker Палитра цветов + Color Picker (Background Color) Палитра цветов (цвет фона) + Color Picker (Custom Canvas Color) Палитра цветов (свой цвет холста) + Color Picker (Foreground Color) Палитра цветов (цвет переднего плана) + Color Sampler Tool Инструмент «Цветовой эталон» + Color Settings Настройки цвета + Color Swatches Образцы цветов + Color Table Цветовая таблица + Color management: {error} Управление цветом: {error} + Colored Pencil Цветной карандаш + Comic Book Комикс + Commit Perspective Warp (↩) Применить перспективную деформацию (↩) + Commit Puppet Warp (↩) Применить марионеточную деформацию (↩) + Commit any current edits (⌘↩) Применить текущие изменения (⌘↩) + Commit current crop operation (↵) Применить текущую операцию кадрирования (↵) + Commit transform Применить трансформирование + Commit transform (↩) Применить трансформирование (↩) + Commit warp (↩) Применить деформацию (↩) + Compare Сравнить + Conditional Mode Change Условная смена режима + Confirm Подтвердить + Conform Подогнать + Constraints Ограничения + Content-Aware Fill needs a selection on a pixel layer Заливка с учётом содержимого требует выделенной области на пиксельном слое + Continue Продолжить + Contrasty Контрастный + Convert Преобразовать + Convert to Alpha Channel Преобразовать в альфа-канал + Convert to Linked Преобразовать в связанный + Convert to Profile Преобразовать в профиль + Cool Холодный + Cooling L B B Охлаждающий (LBB) + Cooling80 Охлаждающий (80) + Cooling82 Охлаждающий (82) + Copy Complete Stroke Копировать полный контур + Copy Fill Копировать заливку + Copyright Url URL авторских прав + Correct C A Коррекция хроматической аберрации + Correct Distortion Коррекция дисторсии + Correct Vignette Коррекция виньетирования + Could not rename the action: {error} Не удалось переименовать операцию: {error} + Could not transform: the layer is empty Не удалось трансформировать: слой пуст + Could not use Perspective Warp: the layer is empty Не удалось применить перспективную деформацию: слой пуст + Could not use Puppet Warp: no opaque pixels Не удалось применить марионеточную деформацию: нет непрозрачных пикселей + Could not use Puppet Warp: the layer is empty Не удалось применить марионеточную деформацию: слой пуст + Couldn't open {name}: {error} Не удалось открыть {name}: {error} + Couldn't save preferences: {error}. Retrying… Не удалось сохранить настройки: {error}. Повтор… + Crack Brightness Яркость трещин + Crack Depth Глубина трещин + Crack Spacing Интервал трещин + Craquelure Кракелюр + Create By Создать с помощью + Create Vector Mask Создать векторную маску + Create Warped Text Создать деформированный текст + Create a new layer (⇧⌘N) Создать новый слой (⇧⌘N) + Create new brush from the current settings Создать новую кисть из текущих параметров + Create new brush preset from the current settings Создать новый набор кистей из текущих параметров + Create… Создать… + Cross Process Кросс-процесс + Crosshatch Перекрёстная штриховка + Crosswise Перекрёстно + Crystallize Кристаллизация + Currency Валюта + Curve Darks Кривая: тёмные тона + Curve Highlights Кривая: светлые тона + Curve Lights Кривая: светлые тона + Curve Shadows Кривая: тени + Curve Splits Кривая: разрывы + Custom Branch Color Свой цвет ветвей + Custom Pixel Aspect Ratio Своё пиксельное соотношение + Custom pattern Свой узор + Cutout Аппликация + Cw По часовой стрелке + Cyans Голубые тона + Cyrillic Кириллица + Daisies Маргаритки + Dark Gray Тёмно-серый + Dark Intensity Интенсивность тёмного + Dark Rough Тёмный шероховатый + Dark Strokes Тёмные штрихи + Darken Only Только затемнение + Darkness Затемнение + Data Данные + Data Set: {name} ({index}/{total}) Набор данных: {name} ({index}/{total}) + Data Sets as Files Наборы данных в файлы + Date Дата + Day For Night День как ночь + De-Interlace Устранение чересстрочности Decrease Brush Hardness Уменьшить жёсткость кисти + Decrease Brush Size Уменьшить размер кисти + Decrease Type Size Уменьшить размер текста + Decrease Type Size by 10 Уменьшить размер текста на 10 + Deep Глубокий + Deep Blue Глубокий синий + Deep Emerald Глубокий изумрудный + Deep Red Глубокий красный + Deep Yellow Глубокий жёлтый + Default Foreground and Background Colors Цвета переднего плана и фона по умолчанию + Default Leaves Листья по умолчанию + Default Photoshop Size Размер Photoshop по умолчанию + Defaults По умолчанию + Define Brush Preset Определить кисть + Define Custom Shape Определить свою фигуру + Define Pattern Определить паттерн + Definition Чёткость + Defringe Убрать кайму + Degrees Градусы + Delete Channel Удалить канал + Delete Mask Удалить маску + Delete Workspace Удалить рабочую среду + Delete brush Удалить кисть + Delete path Удалить контур + Delete vector mask Удалить векторную маску + Delete… Удалить… + Depth Map Карта глубины + Depth Mode Режим глубины + Diameter Диаметр + Diffuse Диффузия + Diffuse Glow Рассеянное свечение + Direction Направление + Direction Balance Баланс направлений + Disable Выключить + Disable Mask Отключить маску + Disable Symmetry Path Отключить контур симметрии + Disabled Отключено + Discard Changes Отменить изменения + Displace Смещение + Display Дисплей + Display profile not used Профиль дисплея не используется + Display profiles: {reason} Профили дисплеев: {reason} + Display-p3 Display P3 + Distance Расстояние + Distortion Дисторсия + Distribution Распределение + Do you want to save you made to “{name}” before quitting? Сохранить изменения в «{name}» перед выходом? + Document Sizes Размеры документа + Documentation Документация + Documents Документы + Dont Enlarge Не увеличивать + Dot Точка + Double Line Двойная линия + Down Вниз + Draft Черновик + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Перетащите рамку кадрирования · перетаскивание внутри перемещает · края изменяют размер (соотношение {ratio}, центр {centre}) · пробел перемещает при рисовании · {commit} применяет · Esc отменяет + Drag: draw freehand · Release on the start point: close Перетаскивание: рисование от руки · Отпустите на начальной точке: замкнуть + Driver: {driver} Драйвер: {driver} + Dry Brush Сухая кисть + Duplicate Channel Дублировать канал + Duplicate Layers Дублировать слои + Duplication Дублирование + Dust & Scratches Пыль и царапины + Edge Край + Edge Brightness Яркость краёв + Edge Extension Расширение краёв + Edge Fidelity Точность краёв + Edge Intensity Интенсивность краёв + Edge Only Только края + Edge Simplicity Простота краёв + Edge Thickness Толщина краёв + Edge Width Ширина краёв + Edit Toolbar Настроить панель инструментов + Edit… Изменить… + Elevation Возвышение + Eliminate Устранить + Emboss Тиснение + Enable Включить + Enable Mask Включить маску + End X Конец X + End Y Конец Y + Enlarged Увеличенный + Erase with Brush or Pencil (hold) Стирание кистью или карандашом (удерживайте) + Error Ошибка + Even Fields Чётные поля + Exit Quick Mask Выйти из быстрой маски + Expand group (⌥-click: all groups) Развернуть группу (⌥+щелчок: все группы) + Export As Экспортировать как + Export As: {name} Экспортировать как: {name} + Export Contents Экспортировать содержимое + Export Image Экспортировать изображение + Export Options Параметры экспорта + Export Preferences Экспорт настроек + Export Settings Экспортировать настройки + Export to Экспортировать в + Export/Import Presets Экспорт/импорт наборов + Exported contents to {path} Содержимое экспортировано в {path} + Exported presets to {path} Наборы экспортированы в {path} + Exported {path} Экспортировано: {path} + Exported {path} ({size}) Экспортировано: {path} ({size}) + Exporting… Экспорт… + ExtraBold Сверхжирный + ExtraLight Сверхтонкий + Extrude Экструзия + Fade Ослабить + Faded Film Выцветшая плёнка + Failed Не удалось + Failed to open file Не удалось открыть файл + Failed to save file Не удалось сохранить файл + Fallback: {reason} Резерв: {reason} + Feather Radius Радиус растушёвки + Fiber Length Длина волокон + Fibers Волокна + Field Blur Размытие по полю + File Format Формат файла + File Info Сведения о файле + File Name Имя файла + File Size Размер файла + File not found Файл не найден + Filename Имя файла + Fill Color Цвет заливки + Fill Opacity Непрозрачность заливки + Fill Path… Залить контур… + Fill path with foreground color Залить контур цветом переднего плана + Fill with Background Color Залить цветом фона + Fill with Background Color, Preserve Transparency Залить цветом фона, сохранить прозрачность + Fill with Foreground Color Залить цветом переднего плана + Fill with Foreground Color, Preserve Transparency Залить цветом переднего плана, сохранить прозрачность + Film Grain Зернистость плёнки + Filter Gallery Галерея фильтров + Find Найти + Find and Replace Text Найти и заменить текст + Fine Тонкий + Fine Dots Мелкие точки + Finish Готово + Fit Image Подогнать изображение + Fit to Window По размеру окна + Flame Пламя + Flame Bottom Alignment Выравнивание низа пламени + Flame Lines Линии пламени + Flame Shape Форма пламени + Flame Style Стиль пламени + Flame Type Тип пламени + Flat Плоский + Flat Shading Плоское затенение + Flower Color Цвет цветков + Focal Distance Дистанция фокуса + Focus Фокус + Focus Area Область фокусировки + Foreground Level Уровень переднего плана + Forward Вперёд + Forward Warp Прямая деформация + Frame Кадр + Frame {current} / {total} Кадр {current} / {total} + Free Transform Path Свободное трансформирование контура + Freeze Lasso Заморозить лассо + Freeze Mask Заморозить маску + Fresco Фреска + From От + From Left Слева + From Right Справа + Frosted Матовое + Full Screen Во весь экран + Fullscreen Полноэкранный + Fuzziness Разброс + GPU backend preference: {backend} Предпочитаемый сервер GPU: {backend} + GPU device was lost; using the CPU renderer. Устройство GPU потеряно; используется отрисовка на CPU. + GPU error; using the CPU renderer. Ошибка GPU; используется отрисовка на CPU. + GPU retry scheduled Запланирована повторная попытка GPU + Gamma Гамма + Gaussian Blur Размытие по Гауссу + General Blending Общее наложение + General Brushes Общие кисти + Generators Генераторы + Generic Общий + Glass Стекло + Global Light Глобальный свет + Gloss Глянец + Glow Amount Величина свечения + Glow Brightness Яркость свечения + Glow Size Размер свечения + Glowing Edges Светящиеся контуры + Good Хорошее + Grade Balance Цветокоррекция: баланс + Grade Blending Цветокоррекция: смешивание + Grade Global Цветокоррекция: общая + Grade Highlights Цветокоррекция: светлые тона + Grade Midtones Цветокоррекция: средние тона + Grade Shadows Цветокоррекция: тени + Gradient Options Параметры градиента + Grain Amount Величина зерна + Grain Roughness Шероховатость зерна + Grain Size Размер зерна + Grain Type Тип зерна + Graininess Зернистость + Grainy Dots Зернистые точки + Graphics adapter Графический адаптер + Graphics adapter: {adapter} Графический адаптер: {adapter} + Gray-gamma-2.2 Серый (гамма 2,2) + Greek Греческий + Greens Зелёные тона + Grid (⌘') Сетка (⌘') + Grid view Вид сеткой + Group from Layers Группа из слоёв + Groups Группы + Grout Width Ширина швов + Guide Layout… Схема направляющих… + HDR Toning HDR-тонирование + Hand Tool (hold) Инструмент «Рука» (удерживайте) + Hearts Сердца + Heptagon Семиугольник + Hexagon Шестиугольник + Hidden Скрыто + Hide Grid Скрыть сетку + Hide Others Скрыть остальные + Hide the layer's effects (⌥-click: all layers) Скрыть эффекты слоя (⌥+щелчок: все слои) + Hide {app} Скрыть {app} + High Pass Цветовой контраст + Highlight Света + Highlight Amount Величина светов + Highlight Area Область светов + Highlight Radius Радиус светов + Highlight Strength Сила светов + Highlight Tone Тон светов + Highlights from: Света от: + Horizontal Distortion Горизонтальная дисторсия + Horizontal Only Только по горизонтали + Horizontal scale Масштаб по горизонтали + Horizontal type Горизонтальный текст + Hotspot Горячая точка + Hsb HSB + Hsl HSL + Hsl Hue Оттенок HSL + Hsl Lum Яркость HSL + Hsl Sat Насыщенность HSL + Image Balance Баланс изображения + Image Processor Обработчик изображений + Image compositor: {processor} Компоновщик изображения: {processor} + Import Brushes Импортировать кисти + Import Brushes… (.abr) Импортировать кисти… (.abr) + Import Image Импортировать изображение + Import Options Параметры импорта + Import Photoshop brushes (.abr) Импортировать кисти Photoshop (.abr) + Import Settings Импортировать настройки + Importing {what} from {file}… Импорт {what} из {file}… + Importing… Импорт… + In Black Входной чёрный + In White Входной белый Increase Brush Hardness Увеличить жёсткость кисти + Demosaic Демозаик + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Как восстанавливается мозаика цветного фильтра камеры. Применяется при открытии файла; превью показывает метод, с которым файл был открыт. + AHD (adaptive homogeneity) AHD (адаптивная однородность) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Хиракава–Паркс: самые чистые края и меньше всего цветной каймы, самый медленный. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Интерполяция с коррекцией градиента: резкая и быстрая, с небольшой цветной каймой на мелких деталях. + The fastest and the softest, with colour fringing on fine detail. Самый быстрый и самый мягкий, с цветной каймой на мелких деталях. + “{name}” already exists. Replace it? «{name}» уже существует. Заменить? + The name in the save dialog was “{name}”. В окне сохранения было указано имя «{name}». + Move Up Переместить вверх + Move Down Переместить вниз + Previous Brush Предыдущая кисть + Next Brush Следующая кисть + First Brush Первая кисть + Last Brush Последняя кисть + Preview size Preview size Memory Usage Использование памяти Per-document RAM limit (MB) Лимит ОЗУ на документ (МБ) Installed RAM: {ram} MB; this limit: {percent}% Установлено ОЗУ: {ram} МБ; этот лимит: {percent}% @@ -3229,5 +3837,752 @@ curves Smooth Сгладить Compression Сжатие Generate Mipmaps Создать MIP-карты +# Color Table (.act) dialog + There is no color table to save. Нет таблицы цветов для сохранения. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Сохранён {n} цвет в {file}|Сохранены {n} цвета в {file}|Сохранено {n} цветов в {file} + # Graphics Adapter preference (#1885) GPU adapter Адаптер GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Сначала подтвердите или отмените открытый диалог шага операции + Pro Medium Gray Средне-серая Pro + Solarized Solarized + Breeze Breeze + Pro Light Gray Светло-серая Pro + Pro Light Светлая Pro + Resize Windows to Fit Подгонять размер окон + + EXR EXR + Increase Brush Size Увеличить размер кисти + Increase Type Size Увеличить размер текста + Increase Type Size by 10 Увеличить размер текста на 10 + Indent first line Отступ первой строки + Indent left margin Отступ слева + Indent right margin Отступ справа + Indexed Индексированный + Infinite Бесконечный + Information Информация + Initial Direction Начальное направление + Ink Outlines Обводка контура + Inner Bevel Внутренняя фаска + Input Вход + Input Levels Входные уровни + Input Mode Режим ввода + Install Plug-in Установить плагин + Intensity Интенсивность + Intent Метод преобразования + Interpret Footage Интерпретировать материал + Intersect with selection (⇧⌥) Пересечение с выделенной областью (⇧⌥) + Interval Интервал + Invalid panel layout; restored the default dock Недопустимый макет панелей; восстановлена пристыковка по умолчанию + Invert Mask Инвертировать маску + Invert Selection Инвертировать выделенную область + Iris Blur Диафрагменное размытие + Ivy Плющ + Jag Зубчатость + Join the ArtCraft Discord Discord сообщества ArtCraft + Join the ArtCraft Discord ({url}) Присоединиться к Discord ArtCraft ({url}) + Keeps the current texture when switching presets. Сохраняет текущую текстуру при смене набора. + Kernel Ядро + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Кернинг: метрический, оптический или значение в 1/1000 em (Alt+←/→ у курсора) + Keyboard Клавиатура + Lab Lab + Landscape, 10 x 8 Альбомная, 10 x 8 + Landscape, 6 x 4 Альбомная, 6 x 4 + Landscape, 7 x 5 Альбомная, 7 x 5 + Latin 1 Латиница 1 + Latin Extended-A Латиница расширенная A + Latin Extended-B Латиница расширенная B + Layer Comps to Files Варианты слоя в файлы + Layer Content Options Параметры содержимого слоя + Layer Count Число слоёв + Layer From Background Слой из фона + Layer Knocks Out Drop Shadow Слой выбивает тень + Layer Mask Hides Effects Маска слоя скрывает эффекты + Layer Properties Свойства слоя + Layer Styles Стили слоя + Layer from Background Слой из фона + Layers to Files Слои в файлы + Layout Макет + Leading (set to the font size × auto-leading when Auto) Интерлиньяж (при значении «Авто» равен размеру шрифта × автоматический интерлиньяж) + Leaf Color Цвет листьев + Learn More Подробнее + Leaves Amount Количество листьев + Leaves Color Цвет листьев + Leaves Size Размер листьев + Left Diagonal Левая диагональ + Legacy Устаревший + Legal Legal + Lens Blur Размытие при малой глубине + Lens Correction Коррекция дисторсии + Lens Flare Блик от объектива + Less Меньше + Letter Letter + Level Based На основе уровней + Library Библиотека + Light Dark Balance Баланс светлого и тёмного + Light Direction Направление света + Light Intensity Интенсивность света + Light Rough Светлый шероховатый + Light Type Тип света + Light X Свет X + Light Y Свет Y + Light Z Свет Z + Lighten Grout Осветлить швы + Lighten Only Только осветление + Lighting Освещение + Lighting Effects Эффекты освещения + Linear Dodge Линейный осветлитель + Linear Height Линейная высота + Linear-srgb Линейный sRGB + Link Связать + Liquify Пластичность + List Список + List view Вид списком + Live Preview Живой просмотр + Load 3D LUT Загрузить 3D LUT + Load Files into Stack Загрузить файлы в стопку + Load path as a selection Загрузить контур как выделенную область + Loading… Загрузка… + Lock All Заблокировать всё + Lock Image Pixels Заблокировать пиксели изображения + Lock Position Закрепить положение + Lock Transparent Pixels Заблокировать прозрачные пиксели + Locked Заблокировано + Long Lines Длинные линии + Long Strokes Длинные штрихи + Lorem Ipsum Lorem Ipsum + Lower Нижний + Lum Ярк. + Lut LUT + Magentas Пурпурные тона + Make Selection from Path Создать выделенную область из контура + Make Selection… Создать выделенную область… + Make Symmetry Path Создать контур симметрии + Make Work Path… Создать рабочий контур… + Make work path from selection Создать рабочий контур из выделенной области + Manual Вручную + Map Document Документ карты + Map Layer Слой карты + Map Path Путь к карте + Mask Channel Канал-маска + Mask Incomplete Blocks Маскировать неполные блоки + Mask None Без маски + Masks Маски + Match Color Подбор цвета + Math Symbols Математические символы + Max Offset Макс. смещение + Max Radius Макс. радиус + Measurement Scale Масштаб измерений + Measurement Scale Marker Маркер масштаба измерений + Medium Dots Средние точки + Medium Gray Средне-серый + Medium Lines Средние линии + Medium Strokes Средние штрихи + Merge Channels… Объединить каналы… + Merge Spot Channel Объединить цветовой канал + Merge to HDR Pro Объединить в HDR Pro + Metallic Металлический + Metrics Метрики + Mezzotint Меццотинто + Middle Середина + Midtone Полутон + Monitor Profile Профиль монитора + Monitor profile in use on {display}: {profile} Используемый профиль монитора на {display}: {profile} + Monitor profile in use: {profile} Используемый профиль монитора: {profile} + Monitor profile on {display} (main window): {profile} Профиль монитора для {display} (главное окно): {profile} + Monitor profile on {display}: {profile} Профиль монитора для {display}: {profile} + Monitor profile: {profile} Профиль монитора: {profile} + Mono Contrast Монохромный контраст + Monochromatic Монохромный + More Больше + Mosaic Мозаика + Mosaic Tiles Мозаичные плитки + Motion Blur Размытие в движении + Mouse Мышь + Move Layer Переместить слой + Movie Prime Киносъёмочный фикс + Multiple Flames Along Path Несколько языков пламени вдоль контура + Multiple Flames One Direction Несколько языков пламени в одном направлении + Multiple Flames Path Directions Несколько языков пламени по направлениям контура + Multiple Flames Various Length Несколько языков пламени разной длины + My Brushes Мои кисти + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Перетаскивание файлов ещё не поддерживается в Wayland. Используйте «Файл › Открыть» или запустите PhotoCraft под XWayland с `WAYLAND_DISPLAY= photocraft`. + Nearest Ближайший + Neon Glow Неоновое свечение + Neutralize Нейтрализовать + Neutrals Нейтральные тона + New Brush Preset Новый набор параметров кисти + New Channel… Новый канал… + New Guide Новая направляющая + New Guide Layout Схема направляющих + New Spot Channel… Новый цветовой канал… + New Video Layer from File Новый видеослой из файла + New Window Новое окно + New Workspace Новая рабочая среда + New document Новый документ + New document… Новый документ… + New document… ⌘N Новый документ… ⌘N + Next Далее + No Document Нет документа + No Image Нет изображения + No Layers Нет слоёв + No Results Нет результатов + No Results Found Ничего не найдено + No Selection Нет выделенной области + No custom shape "{name}": pick one in the options bar or Window › Shapes Нет произвольной фигуры «{name}»: выберите её на панели параметров или в меню «Окно › Фигуры» + No layers Нет слоёв + No recent files Нет недавних файлов + No selection Нет выделенной области + Noise Color Цветовой шум + Noise Color Detail Цветовая детализация шума + Noise Luminance Яркостный шум + Noise Luminance Detail Яркостная детализация шума + Nothing to Redo Нечего повторить + Nothing to Undo Нечего отменить + Nothing to paste: the clipboard holds no image Нечего вставлять: в буфере обмена нет изображения + Number Of Levels Число уровней + Numbers Числа + Ocean Ripple Океанские волны + Octagon Восьмиугольник + Odd Fields Нечётные поля + Oil Paint Масляная живопись + On Вкл. + One Flame Along Path Один язык пламени вдоль контура + Open As Открыть как + Open File Открыть файл + Open Folder Открыть папку + Open Image Открыть изображение + Open Recent is unavailable on the web «Открыть недавние» недоступно в веб-версии + Opened {url} Открыто: {url} + Opening Открытие + Open… ⌘O Открыть… ⌘O + Operation canceled Операция отменена + Operation cancelled Операция отменена + Operation completed Операция завершена + Operation failed Операция не выполнена + Optical Оптический + Original Ratio Исходное соотношение + Out Black Выходной чёрный + Out From Center От центра наружу + Out Of Gamut Вне охвата + Out White Выходной белый + Out of memory Недостаточно памяти + Outer Bevel Внешняя фаска + Output Levels Выходные уровни + Oval Овал + Overlay Edge Наложение края + Overlay: Rule of Thirds Наложение: правило третей + PSD PSD + Package Упаковать + Page Setup… Параметры страницы… + Paint Daubs Мазки краской + Paints from the document's opening state Рисует по состоянию документа на момент открытия + Palette Knife Шпатель + Panel Панель + Paper Бумага + Paper Brightness Яркость бумаги + Parallel Параллельно + Paste Complete Stroke Вставить полный контур + Paste Fill Вставить заливку + Patchwork Лоскутное шитьё + Path Blur Размытие по контуру + Paths to Illustrator Контуры в Illustrator + Pattern Fill… Заливка узором… + Pattern Type Тип узора + Pencil Width Ширина карандаша + Pentagon Пятиугольник + Percentage Процент + Permission denied Доступ запрещён + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft продолжает попытки; пока сохранение не удастся, изменения настроек будут потеряны при закрытии. + Photomerge Фотослияние + Pick Color From Image Выбрать цвет на изображении + Picture Frame Рамка + Pillow Emboss Подушковидное тиснение + Pinch Сжатие + Pins Булавки + Place Embedded Поместить встроенный + Place Linked Поместить связанный + Place Scale Marker Шкала масштаба + Plane Плоскость + Planes Плоскости + Plastic Wrap Пластиковая упаковка + Platform: {os} {arch} Платформа: {os} {arch} + Please wait… Подождите… + Point Точка + Point Curve Точечная кривая + Point Curve Blue Точечная кривая: синяя + Point Curve Green Точечная кривая: зелёная + Point Curve Red Точечная кривая: красная + Pointed Заострённый + Pointillize Пуантилизм + Polar Coordinates Полярные координаты + Polar To Rectangular Полярные в прямоугольные + Policy Cmyk Правила для CMYK + Policy Gray Правила для градаций серого + Policy Rgb Правила для RGB + Pond Ripples Волны на пруду + Portrait, 4 x 6 Книжная, 4 x 6 + Portrait, 5 x 7 Книжная, 5 x 7 + Postcard Открытка + Poster Плакат + Poster Edges Плакатные края + Posterization Постеризация + Preferences can't be saved Не удаётся сохранить настройки + Preferences saved Настройки сохранены + Preferences were reset: {error} Настройки сброшены: {error} + Preferences… Настройки… + Preserve Сохранить + Preset Manager Управление наборами + Press keys Нажмите клавиши + Preview Image Просмотр изображения + Previous Назад + Prime105 Prime 105 + Prime35 Prime 35 + Printed to {path} Напечатано в {path} + Pro (Dark) Pro (тёмная) + Pro (Medium Gray) Pro (средне-серая) + Processing… Обработка… + Profile Профиль + Proof Setup… Настройка пробного отпечатка… + Prophoto-compat ProPhoto RGB (совместимо) + Pucker Сморщивание + Push Left Сдвиг влево + Pyramids Пирамиды + Quick Actions Быстрые действия + Quit Выход + Quit PhotoCraft Выйти из PhotoCraft + Quit {app} Выйти из {app} + R G B R G B + R g b R g b + Radial Радиальный + Radial Blur Радиальное размытие + Radius X Радиус X + Radius Y Радиус Y + Ran script {name} Выполнен сценарий {name} + Randomize Length Случайная длина + Randomize Shapes Случайные фигуры + Rasterize Fill Content Растрировать содержимое заливки + Rasterize Smart Object Растрировать смарт-объект + Ratio Соотношение + Raw Defaults Значения RAW по умолчанию + Reading Чтение + Ready Готово + Rec2020 Rec. 2020 + Recent Files Недавние файлы + Reconstruct All Восстановить всё + Recovered {name} Восстановлено: {name} + Rectangular Selection Прямоугольная выделенная область + Rectangular To Polar Прямоугольные в полярные + Red Cyan Красный–голубой + Reds Красные тона + Reduce Color Noise Уменьшить цветовой шум + Reduce Noise Уменьшить шум + Reflected Отражение + Relief Рельеф + Relink to File Повторно связать с файлом + Remove All Удалить всё + Remove Jpeg Artifact Удалить артефакты JPEG + Rename Brush Переименовать кисть + Rename Channel… Переименовать канал… + Rename Group Переименовать группу + Rename Tool Preset Переименовать набор + Render Video Экспорт видео + Rendering… Отрисовка… + Reorder Layer Изменить порядок слоя + Replace Color Замена цвета + Replace Contents Заменить содержимое + Replace Footage Заменить материал + Report a Bug Сообщить об ошибке + Report an Issue Сообщить о проблеме + Reset Alignment Сбросить выравнивание + Reset All Сбросить всё + Reset Preferences Сбросить настройки + Resolve Missing Fonts Устранить отсутствующие шрифты + Restart Перезапустить + Restart Required Требуется перезапуск + Resume Продолжить + Retry Повторить + Rgb RGB + Ridges Гребни + Right Diagonal Правая диагональ + Ripple Рябь + Ripple Magnitude Величина ряби + Ripple Size Размер ряби + Rope Верёвка + Roses Розы + Rough Pastels Пастель + Row Order Порядок строк + Rulers (⌘R) Линейки (⌘R) + Sample Size Размер образца + Sampled Colors Отобранные цвета + Sandstone Песчаник + Sat Насыщ. + Save As Сохранить как + Save Changes Сохранить изменения + Save File Сохранить файл + Save Image Сохранить изображение + Save Path Сохранить контур + Save Pixel Aspect Ratio Сохранить соотношение сторон пикселя + Save a Copy Сохранить копию + Save before closing? Сохранить перед закрытием? + Save for Web (Legacy) Сохранить для Web (устаревшее) + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Сохраните работу и перезапустите PhotoCraft, чтобы повторить попытку ускорения GPU. Отрисовка на CPU остаётся активной в этом сеансе. + Saved a copy as {path} Копия сохранена как {path} + Saved {path}; image assets: {n} in {dir} Сохранено: {path}; графические ресурсы: {n} в {dir} + Saving… Сохранение… + Scale Effects Масштабировать эффекты + Scaling Масштабирование + Scallops Фестоны + Script Events Manager Диспетчер событий сценариев + Scroll menu down Прокрутить меню вниз + Scroll menu up Прокрутить меню вверх + Search commands Поиск команд + Search commands (⌘K) Поиск команд (⌘K) + Search commands, tools and panels Поиск команд, инструментов и панелей + Search menus Поиск по меню + Seed Зерно + Select Custom Color… Выбрать свой цвет… + Select Data Points Выбрать точки данных + Select Layer Выбрать слой + Select and Mask Выделение и маска + Selected at launch: {backend} Выбрано при запуске: {backend} + SemiBold Полужирный + Sensitivity Чувствительность + Sent to the printer ({spooler}) Отправлено на принтер ({spooler}) + Sepia Сепия + Services Службы + Set Colors Задать цвета + Set Measurement Scale Задать масштаб измерений + Sgray sGray + Shadow Тень + Shadow Amount Величина теней + Shadow Intensity Интенсивность теней + Shadow Radius Радиус теней + Shadow Tone Тон теней + Shadows up to: Тени до: + Shadows/Highlights Тени/Света + Shallow Поверхностный + Shape Blur Размытие по фигуре + Sharpen Amount Величина резкости + Sharpen Detail Детализация резкости + Sharpen Details Резкость деталей + Sharpen Masking Маскирование резкости + Sharpen Radius Радиус резкости + Shear Искривление + Shine Блеск + Short Lines Короткие линии + Short Strokes Короткие штрихи + Shortcut Сочетание клавиш + Shortcuts Сочетания клавиш + Show All Показать все + Show Extras Options Параметры вспомогательных элементов + Show Guides Показать направляющие + Show Guides (⌘;) Показать направляющие (⌘;) + Show Rulers Показать линейки + Show the layer's effects (⌥-click: all layers) Показать эффекты слоя (⌥+щелчок: все слои) + Simple Простой + Simulate Paper Имитировать бумагу + Sine Синусоида + Sketch Эскиз + Skip Пропустить + Slice Options Параметры фрагмента + Smart Blur Умное размытие + Smart Sharpen Умная резкость + Smoothness Сглаженность + Smudge Stick Растушёвка + Snap to Grid Привязать к сетке + Snowflakes Снежинки + Soften Смягчить + Softness Мягкость + Software adapter Программный адаптер + Solarized Dark Solarized (тёмная) + Solid Color Однотонный цвет + Solid Front Faces Сплошные передние грани + Source Channel Канал-источник + Source Invert Инвертировать источник + Source Layer Исходный слой + Source1 Channel Канал источника 1 + Source1 Invert Инвертировать источник 1 + Source2 Channel Канал источника 2 + Source2 Invert Инвертировать источник 2 + Source: set Источник: задан + Space Пробел + Sparkle Искры + Spatter Разбрызгивание + Speckle Крапинки + Speed Скорость + Spherize Сферизация + Spin Вращение + Spin Blur Размытие вращением + Split Channels Разделить каналы + Spot Точечный + Spray Radius Радиус распыления + Sprayed Strokes Разбрызганные штрихи + Spread Разброс + Sprinkles Вкрапления + Square Size Размер квадрата + Square, 12 x 12 Квадратная, 12 x 12 + Square, 5 x 5 Квадратная, 5 x 5 + Squareness Квадратность + Stagger Смещение + Stained Glass Витраж + Star Звезда + Stars Звёзды + Start X Начало X + Start Y Начало Y + Statistics Статистика + Step {step} ({id}) failed: {error} Шаг {step} ({id}) не выполнен: {error} + Stippled Крапчатый + Stops Контрольные точки + Strength Сила + Stretch Растянуть + Stroke Color Цвет обводки + Stroke Detail Детализация обводки + Stroke Direction Направление обводки + Stroke Length Длина обводки + Stroke Pressure Нажим обводки + Stroke Size Размер обводки + Stroke Width Ширина обводки + Stroke path with brush Обвести контур кистью + Strokes Штрихи + Studio (Dark) Studio (тёмная) + Studio (Light) Studio (светлая) + Stylization Стилизация + Subtract Shapes at Overlap Вычесть фигуры при перекрытии + Subtract from selection (⌥) Вычесть из выделенной области (⌥) + Sumi-e Суми-э + Surface Blur Размытие по поверхности + Switch Foreground and Background Colors Поменять цвета переднего плана и фона + System Info Информация о системе + Tablet Планшет + Tabloid Tabloid + Taper Сужение + Target Цель + Target Channel 3 Целевой канал 3 + Target Channel 4 Целевой канал 4 + Target Channel 5 Целевой канал 5 + Target Channel 6 Целевой канал 6 + Target Channel 7 Целевой канал 7 + Target Channel 8 Целевой канал 8 + Target Channel 9 Целевой канал 9 + Target Composite Channel Целевой составной канал + Target X Цель X + Target Y Цель Y + Teal Orange Бирюзовый–оранжевый + Technology Previews… Пробные технологии… + Temporary Временный + Text Layer Текстовый слой + Texture Coverage Покрытие текстуры + Texturizer Текстуризатор + Thaw Mask Разморозить маску + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Холст на {display} отображается как sRGB. Выберите профиль монитора в разделе «Редактирование › Настройки цвета». + The current gradient (pick one in Window › Gradients) Текущий градиент (выберите в меню «Окно › Градиенты») + The document does not have an embedded colour profile. Working space: {working}. Документ не содержит встроенного цветового профиля. Рабочее пространство: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Документ содержит встроенный цветовой профиль, не совпадающий с рабочим пространством.\nВстроенный: {embedded}\nРабочее пространство: {working} + The file could not be opened. Не удалось открыть файл. + The file could not be saved. Не удалось сохранить файл. + Thickness Толщина + This Layer: Этот слой: + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Перед продолжением необходимо растрировать этот смарт-объект. Его содержимое перестанет быть редактируемым. Растрировать смарт-объект? + This adjustment ({key}) is kept as imported and can't be edited yet. Эта коррекция ({key}) сохраняется в импортированном виде и пока не может быть изменена. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Перед продолжением необходимо растрировать этот слой заливки. Содержимое заливки перестанет быть редактируемым. Растрировать слой? + This layer must be rasterized before proceeding. Перед продолжением необходимо растрировать этот слой. + This session: {reason} Этот сеанс: {reason} + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Перед продолжением необходимо растрировать этот слой фигуры. Он перестанет быть редактируемым как фигура. Растрировать фигуру? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Перед продолжением необходимо растрировать этот текстовый слой. Его текст перестанет быть редактируемым. Растрировать текст? + Tile Size Размер плитки + Tiles Плитки + Tilt-Shift Наклон-сдвиг + Tint Color Цвет тонирования + Tiny Lens Маленькая линза + Title Заголовок + To До + To Center К центру + Toggle Layer Mask Overlay Переключить наложение маски слоя + Toggle the Brush Settings panel Переключить панель «Параметры кисти» + Toggle the Brush Settings panel (F5) Переключить панель «Параметры кисти» (F5) + Tool Options Параметры инструмента + Trace Contour Обводка контуров + Tracking (1/1000 em) Трекинг (1/1000 em) + Transformation Трансформирование + Transition Переход + Transparency Shapes Layer Прозрачность задаёт форму слоя + Trap Приводка + Tree Дерево + Trim Обрезать + Turbulent Турбулентность + Twirl Скручивание + Twirl Clockwise Скручивание по часовой стрелке + Twirl Counterclockwise Скручивание против часовой стрелки + Type Options Параметры текста + Unaltered Без изменений + Undefined Areas Неопределённые области + Underlying Layer: Нижележащий слой: + Underpainting Подмалёвок + Underwater Под водой + Ungroup Разгруппировать + Uniform Однородный + Unite Shapes at Overlap Объединить фигуры при перекрытии + Unlink Разъединить + Unlock Разблокировать + Unlocked Разблокировано + Unsaved Changes Несохранённые изменения + Unsharp Mask Нерезкое маскирование + Unsupported file format Неподдерживаемый формат файла + Unsupported format Неподдерживаемый формат + Up Вверх + Update Обновить + Updates Обновления + Upper Верхний + Use Custom Color Использовать свой цвет + Use File › Open to open an image. Используйте «Файл › Открыть», чтобы открыть изображение. + Use Selection In Source Использовать выделение в источнике + Use Selection In Target Использовать выделение в цели + Uses the foreground and background colors. Использует цвета переднего плана и фона. + Vanishing Point Точка схода + Variable Data Sets Наборы данных переменных + Vector Mask Hides Effects Векторная маска скрывает эффекты + Vertical Distortion Вертикальная дисторсия + Vertical Only Только по вертикали + Vertical Type Tool Инструмент «Вертикальный текст» + Vertical scale Масштаб по вертикали + Vertical type Вертикальный текст + Video Frames to Layers Видеокадры в слои + Vignette Amount Величина виньетки + Vignette Feather Растушёвка виньетки + Vignette Highlights Светлые тона виньетки + Vignette Midpoint Средняя точка виньетки + Vignette Roundness Округлость виньетки + Vignette Style Стиль виньетки + Vine Color Цвет лозы + Vine With Flowers Лоза с цветами + Vine With Leaves Лоза с листьями + Violent Сильный + W: Ш: + WIA Support Поддержка WIA + Warm Тёплый + Warming Filter (85) Согревающий фильтр (85) + Warming L B A Согревающий (LBA) + Warming81 Согревающий (81) + Warming85 Согревающий (85) + Warmth Теплота + Warning Предупреждение + Warp Text Деформация текста + Watercolor Акварель + Watercolour look: lighter interior, darker rims. Акварельный вид: светлее внутри, темнее по краям. + Wavelength Max Макс. длина волны + Wavelength Min Мин. длина волны + Waves Волны + Web Large Web (крупный) + Web Minimum Web (минимальный) + Web Most Common Web (наиболее распространённый) + WebP (lossless) WebP (без потерь) + Wetness Влажность + White Clip Отсечение белого + White Intensity Интенсивность белого + White Is High Белое — высокое + Whole Word Слово целиком + Wide Blurry Широкий размытый + Wide Sharp Широкий резкий + Wind Ветер + Window renderer: {renderer} Отрисовка окна: {renderer} + Working Рабочее + Working Cmyk Рабочий CMYK + Working Gray Рабочий: градации серого + Working Rgb Рабочий RGB + Working-cmyk Рабочий CMYK + Workspaces Рабочие среды + Wrap Оборачивание + Yellows Жёлтые тона + ZigZag Зигзаг + Zigzag Зигзаг + Zoom In (hold, drag to scrub) Увеличение (удерживайте, перетащите для прокрутки) + Zoom Out (hold) Уменьшение (удерживайте) + auto авто + blue синий + brushes кисти + fallback резерв + gradients градиенты + gray серый + green зелёный + layer mask маска слоя + manual вручную + none нет + off выключено + on включено + orange оранжевый + previous reading kept; the last re-read failed: {reason} сохранено предыдущее значение; последнее повторное чтение не удалось: {reason} + red красный + unknown неизвестно + unknown type неизвестный тип + vector mask векторная маска + violet фиолетовый + yellow жёлтый + {index} of {total} {index} из {total} + {label} is not available: {reason} {label} недоступно: {reason} + {message} (notes: {n}; {warning}) {message} (примечаний: {n}; {warning}) + {shortcut} assigned. {shortcut} назначено. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} уже используется командой {commands} и будет удалено из неё при нажатии «ОК». + {verb} {mask} {layer} {verb} {mask} {layer} + ↑↓ navigate ↵ run esc close ↑↓ навигация ↵ выполнить esc закрыть + ✓ Color Indicates Masked Areas ✓ Цветом обозначены маскированные области + ✓ Color Indicates Selected Areas ✓ Цветом обозначены выделенные области + + +# Dynamic command-id, plural, and contextual labels (fourth pass). +@id edit.clear Очистить +@id edit.purge.undo Данные отмены +@id edit.stroke Обвести… +@id edit.transform.scale Масштабирование +@id file.close Закрыть +@id filter.sharpen.sharpen Резкость +@id layer.layerMask.apply Применить +@id select.all Выделить всё +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Импортирована {n} кисть из {file}|Импортированы {n} кисти из {file}|Импортировано {n} кистей из {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Импортирован {n} градиент из {file}|Импортированы {n} градиента из {file}|Импортировано {n} градиентов из {file} +@plural Imported {n} pattern from {file}|Imported {n} patterns from {file} Импортирован {n} узор из {file}|Импортированы {n} узора из {file}|Импортировано {n} узоров из {file} +cameraRaw Balance Баланс +cameraRaw Clarity Чёткость +cameraRaw Color Цвет +cameraRaw Color Mixer Смеситель цветов +cameraRaw Contrast Контраст +cameraRaw Curve Кривая +cameraRaw Dehaze Удаление дымки +cameraRaw Detail Детализация +cameraRaw Effects Эффекты +cameraRaw Exposure Экспозиция +cameraRaw Feather Растушёвка +cameraRaw Highlights Света +cameraRaw Luminance Яркость +cameraRaw Masking Маскирование +cameraRaw Midpoint Средняя точка +cameraRaw Midtones Средние тона +cameraRaw Radius Радиус +cameraRaw Roundness Округлость +cameraRaw Saturation Насыщенность +cameraRaw Shadows Тени +cameraRaw Size Размер +cameraRaw Temperature Температура +cameraRaw Texture Текстура +cameraRaw Tint Оттенок +cameraRaw Whites Белые точки +colorRange Invert Инвертировать +document Orientation Ориентация +layerLabel Magenta Пурпурный +layerLabel No Color Без цвета +layerLabel Red Красный +layers Fill Заливка +layers Fill: Заливка: +navigator Zoom Масштаб +shape Appearance Оформление + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Автоматическое направление абзаца + Left-to-right paragraph direction Направление абзаца слева направо + Right-to-left paragraph direction Направление абзаца справа налево + Not rendered: this adjustment ({key}) is kept as imported. Не отображается: эта коррекция ({key}) сохранена как при импорте. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9a1ceff635..9695ced464 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1061,7 +1061,7 @@ Done Gotovo Dots Tačke Drag a rectangle around the object Prevucite pravougaonik oko objekta - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik za brisanje. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik na ograničenje za izmenu ili brisanje. Drag to move the active shape's path or the Work Path Prevucite da pomerite putanju aktivnog oblika ili radnu putanju Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: izaberi sidrišnu tačku · Prevlačenje: pomeri tačku, ručicu ili segment · {shift}-klik: dodaj · {alt}-klik: cela podputanja Draw with the Pen tool (P) or make a work path from a selection. Crtajte alatkom pero (P) ili napravite radnu putanju od selekcije. @@ -1294,6 +1294,8 @@ Override Pressure Nadjačaj pritisak Override Rotation Nadjačaj rotaciju Override Tilt Nadjačaj nagib + Override Tilt X Nadjačaj nagib X + Override Tilt Y Nadjačaj nagib Y PRESET DETAILS DETALJI ŠABLONA PSD PSD Paint Overlay Preklop za slikanje @@ -1446,6 +1448,7 @@ Set shape fill type Podesi vrstu popune oblika Set shape stroke type Podesi vrstu obruba oblika Set the text color Postavi boju teksta + Create warped text Napravi iskrivljeni tekst Shadows Senke Shape: Oblik: Sharpening Izoštravanje @@ -1711,6 +1714,8 @@ Overlay Preklapanje Paint Bucket Tool Alatka kofica s bojom Paints from the document's opening state Slika iz stanja dokumenta pri otvaranju + Paints from the History panel's source state Slika iz izvornog stanja panela Istorija + Sets the source for the history brush Postavlja izvor za četkicu istorije Path Selection Tool Alatka za selekciju putanje Direct Selection Tool Alatka za direktnu selekciju Add Anchor Point Tool Alatka za dodavanje sidrišne tačke @@ -1942,6 +1947,7 @@ Quick export format Format brzog izvoza Quick export location Lokacija brzog izvoza Recent file count Broj nedavnih datoteka + LUT folder Fascikla LUT Recent files Nedavne datoteke Recent fonts Nedavni fontovi Recover on launch Oporavi pri pokretanju @@ -2275,6 +2281,8 @@ Create Snapshot Napravi snimak stanja Delete Snapshot Obriši snimak stanja New Snapshot Novi snimak stanja + New Snapshot… Novi snimak stanja… + History Options… Opcije istorije… Snapshot 1 Snimak stanja 1 Step Forward Korak napred Step Backward Korak nazad @@ -2348,6 +2356,7 @@ Clipping Path Putanja isecanja New Path Nova putanja Duplicate Path Dupliraj putanju + Rename Path… Preimenuj putanju… Delete Path Obriši putanju Auto-Hide Automatski sakrij W Š @@ -2413,6 +2422,8 @@ Keywords Ključne reči Copyright Info URL URL informacija o autorskim pravima Copyright Status Status autorskih prava + Copyright Notice Obaveštenje o autorskim pravima + Document Title Naslov dokumenta Copyrighted Zaštićeno autorskim pravima Public Domain Javno dobro Unknown Nepoznato @@ -2634,6 +2645,7 @@ 32 bit (float) 32 bita (pokretni zarez) Floating point Pokretni zarez Use Global Light Koristi globalno svetlo + Overprint Pretisak Native file drag-and-drop is unavailable Izvorno prevlačenje i otpuštanje datoteka nije dostupno Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili kopirajte sliku u menadžeru datoteka i nalepite je pomoću {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Da biste otpuštali datoteke, pokrenite PhotoCraft pod XWayland-om: `{command}` @@ -2655,6 +2667,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU ubrzanje nije moglo da se nastavi. Vaši dokumenti su nepromenjeni. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft koristi CPU kompozitor slike. Prozor i dalje može da koristi vaš grafički adapter. Retry GPU requires a restart. Save your work first. Ponovni pokušaj sa GPU-om zahteva ponovno pokretanje. Prvo sačuvajte svoj rad. + Retry GPU tries the graphics processor again now. Ponovo pokušaj sa GPU-om odmah ponovo isprobava grafički procesor. + GPU acceleration resumed. GPU ubrzanje je nastavljeno. Keep Using CPU Nastavi sa CPU-om Retry GPU Ponovo pokušaj sa GPU-om GPU Backend GPU pozadinski sistem @@ -2712,6 +2726,11 @@ cameraRaw Blending Stapanje Content-Aware Move Tool Alatka za pomeranje prema sadržaju TIFF Options TIFF opcije Targa Options Targa opcije + JPEG Options JPEG opcije + Image Options Opcije slike + Format Options Opcije formata + Baseline ("Standard") Osnovno („standardno“) + Baseline Optimized Osnovno optimizovano 24 bits/pixel 24 bita/piksel 32 bits/pixel 32 bita/piksel Including layers will increase file size. Uključivanje slojeva povećaće veličinu datoteke. @@ -3095,6 +3114,12 @@ curves Smooth Izgladi Remove after each stroke Ukloni posle svakog poteza Apply the removal ({key}) Primeni uklanjanje ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Potezi za uklanjanje: {n} (Enter primenjuje, Esc otkazuje) + Load Contours… Učitaj konture… + Replace Contours… Zameni konture… + Save Contours… Sačuvaj konture… + Delete Contour Obriši konturu + Loaded contours from {file} Konture učitane iz {file} + Saved contours to {file} Konture sačuvane u {file} # Gradient Editor window, opened from the options bar's gradient swatch. The current gradient (pick one in Window › Gradients) Tekući gradijent (izaberite ga u Prozor › Gradijenti) @@ -4186,11 +4211,15 @@ shape Appearance Izgled Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje Object Finder Pronalazač objekata No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Aktivni sloj ovde nema piksela — uključite Uzorkuj sve slojeve ili izaberite sloj sa pikselima Clear Brush Controls Obriši kontrole četkice Reset All Locked Settings Vrati sva zaključana podešavanja Copy Texture to Other Tools Kopiraj teksturu u druge alatke Load Actions… Učitaj radnje… Save Actions… Sačuvaj radnje… + New Action… Nova radnja… + Start Recording Započni snimanje + Stop Recording Zaustavi snimanje {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Učitana je {n} radnja iz {file}|Učitane su {n} radnje iz {file}|Učitano je {n} radnji iz {file} @@ -4224,12 +4253,24 @@ shape Appearance Izgled # ICO dimensions ICO is limited to 256 × 256 pixels ICO je ograničen na 256 × 256 piksela +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Opcije maske… + Layer Mask Display Options… Opcije prikaza maske sloja… + # Channel Options and New Spot Channel (#3761) Channel Color Boja kanala Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: Redrawing Ponovno iscrtavanje + +# Bevel & Emboss shading controls. + Highlight Mode Режим осветљења + Highlight Color Боја осветљења + Highlight Opacity Непрозирност осветљења + Shadow Mode Режим сенке + Shadow Color Боја сенке + Shadow Opacity Непрозирност сенке # Edit › Content-Aware Fill workspace Sampling Brush Tool Alatka četkica za uzorkovanje Sampling Area Overlay Preklop oblasti uzorkovanja @@ -4263,11 +4304,15 @@ shape Appearance Izgled Chisel Hard Tvrdo klesanje Chisel Soft Meko klesanje Open color-less EXR as ACEScg Otvori EXR bez podatka o bojama kao ACEScg + Do not append "copy" to filename when saving a copy Ne dodaji „copy“ u ime fajla pri čuvanju kopije Show home recent files Prikaži nedavne datoteke na početnom ekranu # View › Guides › right-click a guide Edit Selected Guides Uredi izabrane vodilje Clear Selected Guides Ukloni izabrane vodilje + Bicubic Automatic Bikubično automatski + Bicubic Smoother Bikubično glađe + Bicubic Sharper Bikubično oštrije Pasting from other apps is unavailable Lepljenje iz drugih aplikacija nije dostupno PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft u ovoj sesiji ne može da pristupi sistemskoj ostavi. Na Wayland-u, Flatpak sandbox skriva protokol preko kog se čitaju slike iz drugih aplikacija. To paste from other apps, start PhotoCraft under XWayland: `{command}` Da biste lepili iz drugih aplikacija, pokrenite PhotoCraft pod XWayland-om: `{command}` @@ -4287,6 +4332,44 @@ shape Appearance Izgled set name ime skupa {set} (modified) {set} (izmenjen) Show file name in title Prikaži naziv datoteke u naslovu + +# Image › Mode › Bitmap (#5559) + 50% Threshold Prag 50% + Pattern Dither Dither šarom + Diffusion Dither Difuzni dither + Halftone Screen Polutonski raster + Custom Pattern Prilagođena šara + Frequency: Frekvencija: + Lines/Inch Linija/inč + Lines/Centimeter Linija/centimetar + Ellipse Elipsa + +# Layers panel options + Add “copy” to Copied Layers and Groups Dodaj „kopija” kopiranim slojevima i grupama + Animation Options Opcije animacije + Entire Document Ceo dokument + Expand New Effects Proširi nove efekte + Filter Options Opcije filtera + Layer Bounds Granice sloja + Layers Panel Options Opcije panela Slojevi + New Artboard… Nova radna tabla… + New Group… Nova grupa… + Prevent Auto-Nesting Spreči automatsko ugnežđavanje + Preview on canvas when reordering layers Pregled na platnu pri promeni redosleda slojeva + Show Layers Prikaži slojeve + Show layer mask badges Prikaži oznake maske sloja + Thumbnail Contents Sadržaj sličice + Thumbnail Size Veličina sličice + Use Default Masks on Fill Layers Koristi podrazumevane maske na slojevima popune + +# Smart Filter editors + Blending Options ({filter}) Opcije stapanja ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Sakrij efekte i pametne filtere sloja ({key}-klik: svi slojevi) + Opacity must be between 0% and 100%. Neprozirnost mora biti između 0% i 100%. + Dissolve is approximated: it blends without Photoshop's dither. Rastapanje je približno: mešanje se vrši bez diteringa programa Photoshop. + Show the layer's effects and Smart Filters ({key}-click: all layers) Prikaži efekte i pametne filtere sloja ({key}-klik: svi slojevi) + The document or Smart Filter changed. Reopen its dialog. Dokument ili pametni filter se promenio. Ponovo otvorite njegov dijalog. + Wait for the current job to finish. Sačekajte da se trenutni posao završi. Accept and Go to Conflict Prihvati i idi na sukob Changes undone. Izmene su poništene. Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice. @@ -4304,15 +4387,33 @@ shape Appearance Izgled GPU Memory Full GPU memorija je puna The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Slojevima treba {needed} GPU memorije, a smeju da zauzmu {budget}, pa izmene koje ponovo iscrtavaju ceo dokument to rade na CPU-u. Kliknite da podesite GPU memoriju. GPU memory budget: {budget} Budžet GPU memorije: {budget} + Graphics memory: {size} Grafička memorija: {size} This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) + {path}: the path is not valid Unicode; save under another name. {path}: putanja nije ispravan Unicode; sačuvajte pod drugim imenom. {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. Decrease Brush Size Smanji veličinu četkice Increase Brush Size Povećaj veličinu četkice Decrease Brush Hardness Smanji tvrdoću četkice Increase Brush Hardness Povećaj tvrdoću četkice + Demosaic Demozaikovanje + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kako se popunjava mozaik filtera boja kamere. Primenjuje se pri otvaranju datoteke; pregled prikazuje metod kojim je datoteka otvorena. + AHD (adaptive homogeneity) AHD (adaptivna homogenost) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: najčistije ivice i najmanje obojenih rubova, najsporiji. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Interpolacija sa korekcijom gradijenta: oštra i brza, sa malo obojenih rubova na finim detaljima. + The fastest and the softest, with colour fringing on fine detail. Najbrži i najmekši, sa obojenim rubovima na finim detaljima. + “{name}” already exists. Replace it? „{name}“ već postoji. Zameniti? + The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“. + Move Up Pomeri nagore + Move Down Pomeri nadole + Previous Brush Prethodna četkica + Next Brush Sledeća četkica + First Brush Prva četkica + Last Brush Poslednja četkica + Preview size Preview size Memory Usage Upotreba memorije Per-document RAM limit (MB) RAM limit po dokumentu (MB) Installed RAM: {ram} MB; this limit: {percent}% Instalisan RAM: {ram} MB; ovaj limit: {percent}% @@ -4327,5 +4428,28 @@ shape Appearance Izgled DDS Options DDS opcije Generate Mipmaps Generiši mipmape +# Color Table (.act) dialog + There is no color table to save. Нема табеле боја за чување. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Сачувана је {n} боја у {file}|Сачуване су {n} боје у {file}|Сачувано је {n} боја у {file} + # Graphics Adapter preference (#1885) GPU adapter GPU adapter +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Prvo potvrdite ili otkažite otvoreni dijalog koraka radnje + Pro Medium Gray Pro srednje siva + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro svetlosiva + Pro Light Pro svetla + Resize Windows to Fit Prilagodi veličinu prozora + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Automatski smer pasusa + Left-to-right paragraph direction Smer pasusa sleva nadesno + Right-to-left paragraph direction Smer pasusa zdesna nalevo + Not rendered: this adjustment ({key}) is kept as imported. Није приказано: ово прилагођавање ({key}) је задржано онако како је увезено. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 49bc3bc79b..25cc9fec4f 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -100,6 +100,7 @@ mod tests { (vec!["tr-TR", "en-US"], "tr"), (vec!["sr-Latn-RS", "en-US"], "sr"), (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), + (vec!["hi-IN", "en-US"], "hi"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index def688abcd..342a0ebff4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -16,7 +16,7 @@ Top: Üst: Left: Sol: Bottom: Alt: - Right: Doğru: + Right: Sağ: Target: Hedef: Selected Artboards Seçilen Çalışma Yüzeyleri Center Columns Merkezi Sütunlar @@ -43,18 +43,18 @@ Help Yardım (coming soon) (yakında) New… Yeni… - New from Clipboard Pano'dan yeni + New from Clipboard Panodan Yeni Open… Aç… Open As… Farklı Aç… - Open EXR Parts… EXR Parçalarını açın… - Add beauty precomp from the AOVs AOV'lerden güzellik ön kompozisyonu ekleyin - Both Her ikisi de + Open EXR Parts… EXR Parçalarını Aç… + Add beauty precomp from the AOVs AOV'lerden güzellik ön kompozisyonu ekle + Both Her İkisi Left Sol Right Sağ Open Recent Son Kullanılanları Aç Clear Recent Files Son Dosyaları Temizle - Remove from Recent En Son'dan kaldır - Remove {name} from Recent {name} öğesini En Son'dan kaldır + Remove from Recent Son Kullanılanlardan Kaldır + Remove {name} from Recent {name} öğesini Son Kullanılanlardan kaldır Close Kapat Minimize Küçült Maximize Büyüt @@ -73,36 +73,36 @@ Export Preferences… Dışa Aktarma Tercihleri… Save for Web (Legacy)… Web İçin Kaydet (Eski)… Artboards to Files… Çalışma Yüzeylerinden Dosyalara… - Artboards to PDF… Çalışma yüzeylerinden PDF'ye… + Artboards to PDF… Çalışma Yüzeylerinden PDF'ye… Layers to Files… Katmanlardan Dosyalara… - Layer Comps to Files… Dosyalara Katman Kompozisyonları… + Layer Comps to Files… Katman Kompozisyonlarından Dosyalara… Color Lookup Tables… Renk Arama Tabloları… - Data Sets as Files… Dosya Olarak Veri Kümeleri… - Paths to Illustrator… İllüstratöre Giden Yollar… + Data Sets as Files… Veri Kümelerini Dosya Olarak… + Paths to Illustrator… Yollardan Illustrator'a… Render Video… Videoyu Oluştur… Generate Oluştur - Image Assets Resim Varlıkları + Image Assets Görüntü Varlıkları Place Embedded… Gömülü Olarak Yerleştir… Place Linked… Bağlantılı Olarak Yerleştir… Package… Paketle… Automate Otomatikleştir Batch… Toplu… - Create Droplet… Damlacık oluştur… + Create Droplet… Damlacık Oluştur… Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt Contact Sheet II… İletişim Sayfası II… Conditional Mode Change… Koşullu Mod Değişikliği… - Fit Image… Resmi Sığdır… + Fit Image… Görüntüyü Sığdır… Lens Correction… Mercek Düzeltme… Merge to HDR Pro… HDR Pro'ya Birleştir… Photomerge… Fotoğraf Birleştir… - Scripts Komut dosyaları + Scripts Komut Dosyaları Image Processor… Görüntü İşlemcisi… Delete All Empty Layers Tüm Boş Katmanları Sil Flatten All Layer Effects Tüm Katman Efektlerini Düzleştir Flatten All Masks Tüm Maskeleri Düzleştir Script Events Manager… Komut Dosyası Olay Yöneticisi… - Import İthalat - Browse… Göz at… + Import İçe Aktar + Browse… Gözat… Source Files Kaynak Dosyalar Use: Kullan: Files Dosyalar @@ -120,7 +120,7 @@ Undo Geri Al Redo Yinele Toggle Last State Son Durumu Değiştir - Fade… Solmaya… + Fade… Soldur… Cut Kes Copy Kopyala Copy Merged Birleştirilmiş Olarak Kopyala @@ -138,15 +138,15 @@ No Spelling Suggestions Yazım Önerisi Yok Fill… Doldur… Stroke… Kontur… - Content-Aware Fill… İçeriğe Uygun Dolgu… + Content-Aware Fill… İçeriğe Duyarlı Dolgu… Delete and Fill Selection Seçimi Sil ve Doldur - Content-Aware Scale İçeriğe Uygun Ölçek - Puppet Warp Kukla Çözgü + Content-Aware Scale İçeriğe Duyarlı Ölçekleme + Puppet Warp Kukla Çarpıtma Perspective Warp Perspektif Çarpıtma Free Transform Serbest Dönüştürme - Free Transform a Copy Bir Kopyayı Ücretsiz Dönüştürme + Free Transform a Copy Bir Kopyayı Serbest Dönüştür Transform Dönüştür - Again Yinele + Again Tekrarla Transform Again on a Copy Bir Kopyada Yeniden Dönüştür Scale Ölçekle Rotate Döndür @@ -155,7 +155,7 @@ Skew Eğ Distort Çarpıt Perspective Perspektif - Warp Deforme Et + Warp Çarpıt Flip Horizontal Yatay Çevir Flip Vertical Dikey Çevir Rotate 180° 180° Döndür @@ -169,13 +169,13 @@ Auto-Blend Layers… Katmanları Otomatik Karıştır… Define Brush Preset… Fırça Ön Ayarını Tanımla… Define Pattern… Deseni Tanımla… - Define Custom Shape… Özel Şekil Tanımlayın… + Define Custom Shape… Özel Şekli Tanımla… Purge Temizle Clipboard Pano Histories Geçmiş Video Cache Video Önbelleği All Tümü - Presets Ön ayarlar + Presets Ön Ayarlar Preset Manager… Ön Ayar Yöneticisi… Migrate Presets Ön Ayarları Taşı Export/Import Presets… Ön Ayarları Dışa/İçe Aktar… @@ -192,17 +192,17 @@ Tools… Araçlar… History Log… Geçmiş Günlüğü… File Handling… Dosya İşleme… - Export… İhracat… + Export… Dışa Aktar… Performance… Performans… - Scratch Disks… Kazı Diskleri… + Scratch Disks… Karalama Diskleri… Cursors… İmleçler… - Transparency & Gamut… Şeffaflık ve Gamut… + Transparency & Gamut… Saydamlık ve Gamut… Units & Rulers… Birimler ve Cetveller… Guides, Grid & Slices… Kılavuzlar, Izgaralar ve Dilimler… Plug-ins… Eklentiler… - Type… Tür… + Type… Metin… Enhanced Controls… Gelişmiş Kontroller… - Camera Raw… Kamera Raw… + Camera Raw… Camera Raw… Raw Defaults… Ham Varsayılanlar… Integrations… Entegrasyonlar… AI Integrations… Yapay Zeka Entegrasyonları… @@ -210,14 +210,14 @@ Language Options Dil Seçenekleri Settings… Ayarlar… Mode Mod - Bitmap Bit eşlem - Grayscale Gri tonlamalı - Duotone Çift tonlu + Bitmap Bit Eşlem + Grayscale Gri Tonlamalı + Duotone Çift Tonlu Indexed Color İndekslenmiş Renk RGB Color RGB Rengi CMYK Color CMYK Rengi - Lab Color Laboratuvar Rengi - Multichannel Çok kanallı + Lab Color Lab Renk + Multichannel Çok Kanallı 8 Bits/Channel 8 Bit/Kanal 8 bit 8 bitlik Integer Tamsayı @@ -235,8 +235,8 @@ Levels… Düzeyler… Curves… Eğriler… Exposure… Pozlama… - Vibrance… Titreşim… - Hue/Saturation… Renk Tonu/Doygunluk… + Vibrance… Canlılık… + Hue/Saturation… Ton/Doygunluk… Color Balance… Renk Dengesi… Black & White… Siyah ve Beyaz… Photo Filter… Fotoğraf Filtresi… @@ -259,42 +259,42 @@ Image Size… Görüntü Boyutu… Canvas Size… Tuval Boyutu… Image Rotation Görüntü Döndürme - Flip Canvas Horizontal Kanvası Yatay Çevir - Flip Canvas Vertical Kanvası Dikey Çevir + Flip Canvas Horizontal Tuvali Yatay Çevir + Flip Canvas Vertical Tuvali Dikey Çevir Crop Kırp Trim… Kırp (Kenar)… Reveal All Tümünü Göster Duplicate… Çoğalt… - Apply Image… Resmi Uygula… + Apply Image… Görüntüyü Uygula… Calculations… Hesaplamalar… Variables Değişkenler Define… Tanımla… - Data Sets… Veri Setleri… + Data Sets… Veri Kümeleri… Apply Data Set… Veri Kümesini Uygula… - Variable Data Sets… Değişken Veri Setleri… + Variable Data Sets… Değişken Veri Kümeleri… Trap… Tuzak… Analysis Analiz Set Measurement Scale… Ölçüm Ölçeğini Ayarla… - Select Data Points… Veri Noktalarını Seçin… + Select Data Points… Veri Noktalarını Seç… Record Measurements Ölçümleri Kaydet Ruler Tool Cetvel Aracı Count Tool Sayma Aracı - Place Scale Marker… Ölçek İşaretçisini Yerleştirin… + Place Scale Marker… Ölçek İşaretçisini Yerleştir… Custom… Özel… New Yeni Layer… Katman… Group… Grup… Group from Layers… Katmanlardan Grup… - Artboard… Çalışma yüzeyi… - Artboard from Group… Gruptan çalışma yüzeyi… + Artboard… Çalışma Yüzeyi… + Artboard from Group… Gruptan Çalışma Yüzeyi… Artboard from Layers… Katmanlardan Çalışma Yüzeyi… - Layer via Copy Kopyalama yoluyla katman + Layer via Copy Kopyalama Yoluyla Katman Layer via Cut Kesim Yoluyla Katman Layer From Background… Arka Plandan Katman… Layer from Background… Arka Plandan Katman… Background From Layer Katmandan Arka Plan New Shape Layer Yeni Şekil Katmanı - New Type Layer Yeni Tip Katmanı + New Type Layer Yeni Metin Katmanı New Fill Layer Yeni Dolgu Katmanı New Adjustment Layer Yeni Ayarlama Katmanı New Layer Based Slice Yeni Katman Tabanlı Dilim @@ -309,47 +309,48 @@ Layer Style Katman Stili Blending Options… Karışım Seçenekleri… Bevel & Emboss… Eğim ve Kabartma… - Use Global Light Global Light'ı kullanın + Use Global Light Genel Işığı Kullan + Overprint Üst Baskı Inner Shadow… İç Gölge… Inner Glow… İç Işıma… Satin… Saten… Color Overlay… Renk Kaplama… - Gradient Overlay… Degrade Kaplama… - Pattern Overlay… Desen Kaplama… + Gradient Overlay… Degrade Kaplaması… + Pattern Overlay… Desen Kaplaması… Outer Glow… Dış Işıma… Drop Shadow… Alt Gölge… Copy Layer Style Katman Stilini Kopyala Paste Layer Style Katman Stilini Yapıştır Clear Layer Style Katman Stilini Temizle - Global Light… Küresel Işık… + Global Light… Genel Işık… Hide All Effects Tüm Efektleri Gizle - Scale Effects… Ölçek Etkileri… + Scale Effects… Efektleri Ölçekle… Smart Filter Akıllı Filtre - Convert for Smart Filters Akıllı Filtreler için Dönüştürme + Convert for Smart Filters Akıllı Filtreler için Dönüştür Clear Smart Filters Akıllı Filtreleri Temizle Disable Smart Filters Akıllı Filtreleri Devre Dışı Bırak Delete Filter Mask Filtre Maskesini Sil Disable Filter Mask Filtre Maskesini Devre Dışı Bırak Solid Color… Düz Renk… - Gradient… Gradyan… + Gradient… Degrade… Pattern… Desen… Layer Mask Katman Maskesi - Vector Mask vektör maskesi + Vector Mask Vektör Maskesi Reveal Selection Seçimi Göster Hide Selection Seçimi Gizle Hide All Tümünü Gizle - From Transparency Şeffaflıktan + From Transparency Saydamlıktan Delete Vector Mask Vektör Maskesini Sil Enable Layer Mask Katman Maskesini Etkinleştir Disable Layer Mask Katman Maskesini Devre Dışı Bırak Enable Vector Mask Vektör Maskesini Etkinleştir - Link Layer Mask Bağlantı Katmanı Maskesi + Link Layer Mask Katman Maskesini Bağla Unlink Layer Mask Katman Maskesinin Bağlantısını Kaldır - Link Vector Mask Bağlantı Vektör Maskesi + Link Vector Mask Vektör Maskesini Bağla Add Vector Mask Vektör Maskesi Ekle Vector Mask from Current Path Geçerli Yoldan Vektör Maskesi Create Clipping Mask Kırpma Maskesi Oluştur - Release Clipping Mask Kırpma Maskesini Serbest Bırakın + Release Clipping Mask Kırpma Maskesini Serbest Bırak Smart Objects Akıllı Nesneler Smart Object Akıllı Nesne Convert to Smart Object Akıllı Nesneye Dönüştür @@ -367,53 +368,53 @@ Entropy Entropi Kurtosis Basıklık Maximum Maksimum - Mean ortalama + Mean Ortalama Median Medyan - Minimum Asgari + Minimum Minimum Range Aralık - Skewness çarpıklık + Skewness Çarpıklık Standard Deviation Standart Sapma Summation Toplama Variance Varyans Video Layers Video Katmanları Interpret Footage… Görüntüyü Yorumla… Replace Footage… Görüntüyü Değiştir… - Reload Frame Çerçeveyi Yeniden Yükle + Reload Frame Kareyi Yeniden Yükle Show Altered Video Değiştirilen Videoyu Göster - Restore Frame Çerçeveyi Geri Yükle - Restore All Frames Tüm Çerçeveleri Geri Yükle - Insert Blank Frame Boş Çerçeve Ekle - Delete Frame Çerçeveyi Sil - Duplicate Frame Çerçeveyi Çoğalt - Frame from Layers Katmanlardan Çerçeve + Restore Frame Kareyi Geri Yükle + Restore All Frames Tüm Kareleri Geri Yükle + Insert Blank Frame Boş Kare Ekle + Delete Frame Kareyi Sil + Duplicate Frame Kareyi Çoğalt + Frame from Layers Katmanlardan Kare Video Frames to Layers… Video Karelerinden Katmanlara… Rasterize Rasterleştir - Rasterize Type Rasterleştirme Türü - Rasterize Type Layer Tip Katmanını Rasterleştir + Rasterize Type Metni Rasterleştir + Rasterize Type Layer Metin Katmanını Rasterleştir Rasterize Shape Şekli Rasterleştir New Guide Layout… Yeni Kılavuz Düzeni… - New Guides From Shape Shape'den Yeni Kılavuzlar + New Guides From Shape Şekilden Yeni Kılavuzlar Lock Layers… Katmanları Kilitle… Link Layers Katmanları Bağla - Select Linked Layers Bağlantılı Katmanları Seçin + Select Linked Layers Bağlantılı Katmanları Seç Merge Layers Katmanları Birleştir Merge Down Aşağı Birleştir Merge Visible Görünürleri Birleştir - Stamp Visible Damga Görünür - Select Layer Above Yukarıdaki Katmanı Seçin - Select Layer Below Aşağıdaki Katmanı Seçin - Select Top Layer Üst Katmanı Seçin - Select Bottom Layer Alt Katmanı Seçin + Stamp Visible Görünür Katmanları Damgala + Select Layer Above Yukarıdaki Katmanı Seç + Select Layer Below Aşağıdaki Katmanı Seç + Select Top Layer Üst Katmanı Seç + Select Bottom Layer Alt Katmanı Seç Add Layer Above to Selection Seçime Yukarıdaki Katmanı Ekle Add Layer Below to Selection Aşağıdaki Katmanı Seçime Ekle - Stamp Down Damgalama + Stamp Down Aşağıya Damgala Flatten Image Görüntüyü Düzleştir - Matting Paspas + Matting Matlama Color Decontaminate… Renkli Dekontamine… Remove Black Matte Siyah Matı Kaldır Remove White Matte Beyaz Matı Kaldır - Defringe… Borcu kaldır… - Group Layers Grup Katmanları + Defringe… Kenar Halesini Kaldır… + Group Layers Katmanları Gruplandır Ungroup Layers Katmanların Grubunu Çöz Hide Layers Katmanları Gizle Arrange Sırala @@ -423,9 +424,9 @@ Send to Back En Arkaya Gönder Reverse Ters Combine Shapes Şekilleri Birleştir - Unite Shapes Şekilleri Birleştir - Subtract Front Shape Ön Şekli Çıkart - Intersect Shape Areas Şekil Alanlarını Kesiş + Unite Shapes Şekil Alanlarını Birleştir + Subtract Front Shape Ön Şekli Çıkar + Intersect Shape Areas Şekil Alanlarını Kesiştir Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut Merge Shape Components Şekil Bileşenlerini Birleştir Align Hizala @@ -436,7 +437,7 @@ Horizontal Centers Yatay Merkezler Right Edges Sağ Kenarlar Distribute Dağıt - Vertically Dikey olarak + Vertically Dikey Olarak Horizontally Yatay olarak Layer Edges Katman Kenarları Isolate Layers Katmanları Yalıt @@ -462,7 +463,7 @@ Crisp Çıtır Strong Güçlü Smooth Pürüzsüz - Windows LCD Windows LCD'si + Windows LCD Windows LCD Windows Windows Orientation Oryantasyon Horizontal Yatay @@ -471,25 +472,25 @@ Standard Ligatures Standart Bitişik Harfler Contextual Alternates Bağlamsal Alternatifler Discretionary Ligatures İsteğe Bağlı Bitişik Harfler - Swash çalkalama - Oldstyle Eski tarz + Swash Süslü Harfler + Oldstyle Eski Stil Stylistic Alternates Stilistik Alternatifler Titling Alternates Başlık Alternatifleri Ornaments Süsler Ordinals Sıra Sayıları Fractions Kesirler - Create Work Path İş Yolu Oluştur + Create Work Path Çalışma Yolu Oluştur Convert to Shape Şekle Dönüştür - Warp Text… Metni Çözgü… + Warp Text… Metni Çarpıt… Convert to Point Text Nokta Metnine Dönüştür Convert to Paragraph Text Paragraf Metnine Dönüştür Default Features Varsayılan Özellikler East Asian Features Doğu Asya Özellikleri Middle Eastern Features Orta Doğu Özellikleri - Middle Eastern & South Asian Composer Orta Doğulu ve Güney Asyalı Besteci + Middle Eastern & South Asian Composer Orta Doğu ve Güney Asya Dizgi Motoru Update All Text Layers Tüm Metin Katmanlarını Güncelle Replace All Missing Fonts Tüm Eksik Yazı Tiplerini Değiştir - Resolve Missing Fonts… Eksik Yazı Tiplerini Çözün… + Resolve Missing Fonts… Eksik Yazı Tiplerini Çöz… Font Preview Size Yazı Tipi Önizleme Boyutu None Yok Small Küçük @@ -497,8 +498,8 @@ Large Büyük Extra Large Ekstra Büyük Huge Devasa - Load Default Type Styles Varsayılan Yazım Stillerini Yükle - Save Default Type Styles Varsayılan Yazım Stillerini Kaydet + Load Default Type Styles Varsayılan Metin Stillerini Yükle + Save Default Type Styles Varsayılan Metin Stillerini Kaydet Deselect Seçimi Kaldır Reselect Yeniden Seç Inverse Ters Çevir @@ -510,7 +511,7 @@ Smart Radius Akıllı Yarıçap Shift Edge Kenarı Kaydır Decontaminate Dekontaminasyon - Refine Edge Kenarı İyileştir + Refine Edge Kenarı Hassaslaştır Modify Değiştir Border… Kenarlık… Smooth… Düzelt… @@ -524,7 +525,7 @@ Save Selection… Seçimi Kaydet… Match All Tümünü Eşleştir Last Filter Son Filtre - Filter Gallery… Galeriyi Filtrele… + Filter Gallery… Filtre Galerisi… Adaptive Wide Angle… Uyarlanabilir Geniş Açı… Camera Raw Filter… Camera Raw Filtresi… Liquify… Sıvılaştır… @@ -546,34 +547,34 @@ Gaussian Blur… Gauss Bulanıklığı… Lens Blur… Mercek Bulanıklığı… Motion Blur… Hareket Bulanıklığı… - Radial Blur… Radyal Bulanıklık… + Radial Blur… Radyal Bulanıklığı… Shape Blur… Şekil Bulanıklığı… Smart Blur… Akıllı Bulanıklaştırma… Surface Blur… Yüzey Bulanıklığı… Average Ortalama - Displace… Yer değiştir… - Pinch… Tut… + Displace… Yer Değiştir… + Pinch… Sıkıştır… Polar Coordinates… Kutupsal Koordinatlar… Ripple… Dalgalanma… Shear… Kesme… Spherize… Küreselleştir… - Twirl… Döndür… + Twirl… Girdap… Wave… Dalga… - ZigZag… ZigZag… + ZigZag… Zikzak… Noise Gürültü Add Noise… Gürültü Ekle… - Despeckle Benek giderme + Despeckle Benekleri Gider Dust & Scratches… Toz ve Çizikler… Median… Medyan… - Reduce Noise… Gürültüyü Azaltın… + Reduce Noise… Gürültüyü Azalt… Pixelate Pikselleştir Color Halftone… Renk Yarım Tonu… - Crystallize… Kristalleşin… + Crystallize… Kristalleştir… Facet Faset Fragment Parça Mezzotint… Mezzotint… Mosaic… Mozaik… - Pointillize… Noktalaştırın… + Pointillize… Noktalaştır… Render İşleme Clouds Bulutlar Difference Clouds Fark Bulutları @@ -581,17 +582,17 @@ Flame… Alev… Lens Flare… Mercek Parlaması… Lighting Effects… Aydınlatma Efektleri… - Relight… Yeniden aydınlat… + Relight… Yeniden Aydınlat… Picture Frame… Resim Çerçevesi… Tree… Ağaç… Sharpen Keskinleştir - Sharpen Edges Kenarları Keskinleştirin + Sharpen Edges Kenarları Keskinleştir Sharpen More Daha Fazla Keskinleştir Smart Sharpen… Akıllı Keskinleştirme… Unsharp Mask… Keskinliği Azaltma Maskesi… High Pass… Yüksek Geçiş… Stylize Stilize Et - Diffuse… Yaygın… + Diffuse… Yay… Emboss… Kabartma… Extrude… Ekstrüzyon… Find Edges Kenarları Bul @@ -599,29 +600,29 @@ Solarize Solarizasyon Tiles… Fayans… Trace Contour… Konturu İzle… - Wind… Rüzgar… - Video video + Wind… Rüzgâr… + Video Video De-Interlace… Taramasızlaştırma… NTSC Colors NTSC Renkleri Other Diğer Maximum… Maksimum… - Minimum… Asgari… - Offset… Ofset… + Minimum… Minimum… + Offset… Kaydırma… Plug-ins Eklentiler Proof Setup Prova Kurulumu Proof Colors Prova Renkleri Gamut Warning Gamut Uyarısı - Working CMYK CMYK'de çalışıyor - Working Cyan Plate Çalışan Camgöbeği Plaka - Working Magenta Plate Çalışan Macenta Plaka - Working Yellow Plate Sarı Plaka Çalışması - Working Black Plate Siyah Plaka Çalışma - Working CMY Plate Çalışan CMY Plakası + Working CMYK Çalışma CMYK'si + Working Cyan Plate Çalışma Camgöbeği Plakası + Working Magenta Plate Çalışma Macenta Plakası + Working Yellow Plate Çalışma Sarı Plakası + Working Black Plate Çalışma Siyah Plakası + Working CMY Plate Çalışma CMY Plakası Legacy Macintosh RGB Eski Macintosh RGB Internet Standard RGB İnternet Standardı RGB - Monitor RGB RGB'yi izleyin + Monitor RGB Monitör RGB Color Blindness — Protanopia-type Renk Körlüğü — Protanopi tipi - Color Blindness — Deuteranopia-type Renk Körlüğü – Döteranopi tipi + Color Blindness — Deuteranopia-type Renk Körlüğü — Döteranopi tipi Zoom In Yakınlaştır Zoom Out Uzaklaştır Fit on Screen Ekrana Sığdır @@ -635,7 +636,7 @@ Custom Pixel Aspect Ratio… Özel Piksel En Boy Oranı… Square Kare D1/DV NTSC (0.91) D1/DV NTSC (0,91) - D1/DV NTSC Widescreen (1.21) D1/DV NTSC Geniş Ekran (1.21) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC Geniş Ekran (1,21) D1/DV PAL (1.09) D1/DV PAL (1,09) D1/DV PAL Widescreen (1.46) D1/DV PAL Geniş Ekran (1,46) Anamorphic 2:1 (2) Anamorfik 2:1 (2) @@ -646,7 +647,7 @@ Standard Screen Mode Standart Ekran Modu Full Screen Mode With Menu Bar Menü Çubuklu Tam Ekran Modu Full Screen Mode Tam Ekran Modu - Cycle Screen Mode Döngü Ekranı Modu + Cycle Screen Mode Ekran Modları Arasında Geç Show Göster Extras Ekstralar Selection Edges Seçim Kenarları @@ -654,7 +655,7 @@ Current Path Geçerli Yol Grid Izgara Guides Kılavuzlar - Canvas Guides Kanvas Kılavuzları + Canvas Guides Tuval Kılavuzları Artboard Guides Çalışma Yüzeyi Kılavuzları Count Sayım Smart Guides Akıllı Kılavuzlar @@ -665,8 +666,8 @@ Pixel Art Preview Piksel Sanat Önizlemesi Pattern Preview Desen Önizlemesi Brush Preview Fırça Önizlemesi - Mesh ağ - Edit Pins Pinleri Düzenle + Mesh Ağ + Edit Pins Pimleri Düzenle Document Bounds Belge Sınırları Show Extras Options… Ekstra Seçeneklerini Göster… Rulers Cetveller @@ -675,15 +676,15 @@ Lock Guides Kılavuzları Kilitle Clear Guides Kılavuzları Temizle Clear Selected Artboard Guides Seçilen Çalışma Yüzeyi Kılavuzlarını Temizle - Clear Canvas Guides Şeffaf Kanvas Kılavuzları + Clear Canvas Guides Tuval Kılavuzlarını Temizle New Guide… Yeni Kılavuz… Lock Slices Dilimleri Kilitle Clear Slices Dilimleri Temizle Match Zoom Yakınlaştırmayı Eşleştir - Match Location Eşleşme Konumu - Match Rotation Maç Rotasyonu - Cascade Çağlayan - Tile Fayans + Match Location Konumu Eşleştir + Match Rotation Döndürmeyi Eşleştir + Cascade Basamakla + Tile Döşe Tile All Horizontally Tümünü Yatay Olarak Döşe Tile All Vertically Tümünü Dikey Olarak Döşe 2-up Horizontal 2'li Yatay @@ -694,11 +695,11 @@ 4-up 4'lü 6-up 6'lı Consolidate All to Tabs Tümünü Sekmelerde Birleştir - Float in Window Pencerede Kaydır - Float All in Windows Tümünü Windows'ta Kaydır + Float in Window Pencerede Yüzdür + Float All in Windows Tümünü Pencerelerde Yüzdür New Window for Document Belge için Yeni Pencere Workspace Çalışma alanı - Essentials Temel bilgiler + Essentials Temel Öğeler Graphic and Web Grafik ve Web Painting Boyama Photography Fotoğrafçılık @@ -710,27 +711,27 @@ Reset all tools to their default settings? Tüm araçlar varsayılan ayarlarına sıfırlansın mı? Lock Workspace Çalışma Alanını Kilitle Theme Tema - Pro Theme Profesyonel Tema + Pro Theme Pro Tema Pro Medium Gray Theme Pro Orta Gri Tema Pro Light Gray Theme Pro Açık Gri Tema - Pro Light Theme Profesyonel Işık Teması + Pro Light Theme Pro Açık Tema Studio Theme Stüdyo Teması - Studio Light Theme Stüdyo Işık Teması + Studio Light Theme Stüdyo Açık Tema Classic Theme Klasik Tema - Solarized dark Solarize karanlık - Solarized Dark Theme Solarize Karanlık Tema - Breeze dark Esinti karanlık - Breeze Dark Theme Esinti Koyu Tema - Breeze light Esinti ışığı - Breeze Light Theme Esinti Işık Teması + Solarized dark Solarized koyu + Solarized Dark Theme Solarized Koyu Tema + Breeze dark Breeze koyu + Breeze Dark Theme Breeze Koyu Tema + Breeze light Breeze açık + Breeze Light Theme Breeze Açık Tema Soft dark Yumuşak koyu Soft light Yumuşak açık Soft Dark Theme Yumuşak koyu tema Soft Light Theme Yumuşak açık tema - Adwaita Light Theme Adwaita Işık Teması - Adwaita Dark Theme Adwaita Karanlık Tema + Adwaita Light Theme Adwaita Açık Tema + Adwaita Dark Theme Adwaita Koyu Tema Adwaita Adwaita - Adwaita dark Adwaita karanlık + Adwaita dark Adwaita koyu Next Theme Sonraki Tema Actions Eylemler Brush Settings Fırça Ayarları @@ -743,7 +744,7 @@ Glyphs Glifler Gradients Degradeler Histogram Histogram - History Tarih + History Geçmiş Info Bilgi Measurement Log Ölçüm Günlüğü Modifier Keys Değiştirici Tuşlar @@ -757,15 +758,15 @@ Shapes Şekiller Styles Stiller Swatches Renk Örnekleri - Timeline Zaman çizelgesi + Timeline Zaman Çizelgesi Tool Presets Araç Ön Ayarları Tools Araçlar About PhotoCraft PhotoCraft Hakkında PhotoCraft Website PhotoCraft Web Sitesi PhotoCraft on GitHub GitHub'da PhotoCraft ArtCraft Website ArtCraft Web Sitesi - Join the ArtCraft Discord… ArtCraft Discord'una katılın… - Report an Issue… Bir Sorun Bildirin… + Join the ArtCraft Discord… ArtCraft Discord'una Katıl… + Report an Issue… Sorun Bildir… WIA Support… WIA Desteği… Apply Uygula HSB/HSL HSB/HSL @@ -774,16 +775,16 @@ Motion Hareket Notes… Notlar… Other… Diğer… - Remove Warp Split Warp Split'i Kaldır + Remove Warp Split Çarpıtma Bölünmesini Kaldır Shape Şekil Split Warp Crosswise Çözgüyü Çapraz Olarak Böl Split Warp Horizontally Çözgüyü Yatay Olarak Böl Split Warp Vertically Çözgüyü Dikey Olarak Böl Statistics… İstatistikler… - Refine Strokes Konturları İyileştir + Refine Strokes Fırça Vuruşlarını Hassaslaştır @plural {n} item|{n} items {n} öğe Auto Otomatik - (print to the printer) (yazıcıya yazdırın) + (print to the printer) (yazıcıya yazdır) + Add variable + Değişken ekle 30 frames @ 30 fps 30 kare @ 30 fps 3D LUT File 3D LUT Dosyası @@ -795,34 +796,34 @@ Add Ekle Add Layer Mask Katman Maskesi Ekle Add Stop Durak Ekle - Add a layer style Katman stili ekleme + Add a layer style Katman stili ekle Add a mask (from the selection; {key} inverts) Bir maske ekleyin (seçimden; {key} tersine çevirir) - Add an adjustment Düzenleme ekle + Add an adjustment Ayarlama ekle Add disk Disk ekle Add space after paragraph Paragraftan sonra boşluk ekle - Add space before paragraph Paragraftan önce boşluk ekleyin + Add space before paragraph Paragraftan önce boşluk ekle Add to Sample Örneğe Ekle Add to selection ({key}) Seçime ekle ({key}) Adjust for Zoom Yakınlaştırma için Ayarlama - Adjustment visible Ayar görünür + Adjustment visible Ayarlama görünür Adobe RGB (1998) compatible Adobe RGB (1998) uyumlu Align and Distribute Hizala ve Dağıt Align and distribute Hizala ve dağıt - Align horizontal centers Yatay merkezleri hizalayın + Align horizontal centers Yatay merkezleri hizala Align left edges Sol kenarları hizala - Align right edges Sağ kenarları hizalayın + Align right edges Sağ kenarları hizala Aligned Hizalı All Caps Tamamı Büyük Harf All Slices Tüm Dilimler All User Slices Tüm Kullanıcı Dilimleri All shortcuts reset to PhotoCraft defaults. Tüm kısayollar PhotoCraft varsayılanlarına sıfırlanır. - Alt Alternatif - Always use pressure for opacity Opaklık için her zaman baskıyı kullanın - Always use pressure for size Her zaman boyuta göre baskı kullanın + Alt Alt + Always use pressure for opacity Opaklık için her zaman basıncı kullan + Always use pressure for size Boyut için her zaman basıncı kullan Amount Miktar - Anchor: Çapa: + Anchor: Çapa noktası: Angle Açı - Angle Gradient Açı Gradyanı + Angle Gradient Açısal Degrade Angle Jitter Açı Değişimi Answer the unsaved-changes prompt first Önce kaydedilmemiş değişiklikler istemini yanıtlayın Anti-alias Kenar yumuşatma @@ -832,24 +833,24 @@ Add Mask to Selection Maskeyi Seçime Ekle Subtract Mask from Selection Maskeyi Seçimden Çıkar Intersect Mask with Selection Maskeyi Seçimle Kesiştir - Apply Per Tip İpucu Başına Uygula + Apply Per Tip Uç Başına Uygula Apply next selected layer comp Sonraki seçilen katman kompozisyonunu uygula Apply previous selected layer comp Önceki seçilen katman kompozisyonunu uygula Artboard Çalışma yüzeyi Artboard Background Color Çalışma Yüzeyi Arka Plan Rengi - Assign working space Çalışma alanı atayın + Assign working space Çalışma renk uzayını ata Author: Yazar: Auto Hide Otomatik Gizle Auto-Select: Otomatik Seç: Automatically level near-horizontal line segments Yataya yakın çizgi bölümlerini otomatik olarak hizalayın - Automatically straighten and level Otomatik olarak düzeltin ve seviyelendirin - Automatically straighten near-vertical line segments Dikeye yakın çizgi bölümlerini otomatik olarak düzelt + Automatically straighten and level Otomatik olarak doğrultun ve hizalayın + Automatically straighten near-vertical line segments Dikeye yakın çizgi bölümlerini otomatik olarak doğrultun Background Arka plan Background Color Arka Plan Rengi Background Contents Arka Plan İçeriği Custom background color Özel Arka Plan Rengi - Balance Bakiye - Baseline Temel + Balance Denge + Baseline Taban Çizgisi Baseline shift Taban çizgisi kayması Basic Temel Before (Y) Önce (Y) @@ -861,7 +862,7 @@ Bicubic Sharper (reduction) Bikübik Daha Keskin (azaltma) Bilinear Çift doğrusal Black Siyah - Black & White Siyah Beyaz + Black & White Siyah ve Beyaz Black Point Compensation Kara Nokta Telafisi Black, White Siyah, Beyaz Blacks Siyahlar @@ -872,9 +873,9 @@ Both Axes Her İki Eksen Bottom Alt Brightness Parlaklık - Brightness Jitter Parlaklık Değişimi + Brightness Jitter Parlaklık Titreşimi Brightness/Contrast Parlaklık/Kontrast - Bring the selected pin forward Seçilen pini öne getir + Bring the selected pin forward Seçilen pimi öne getir Brush Preset picker Fırça Ön Ayarı seçici Brush Reconstruct Options Fırçayla Yeniden Yapılandırma Seçenekleri Brush Tool Options Fırça Aracı Seçenekleri @@ -884,15 +885,15 @@ Reset Sıfırla Cancel (Esc) İptal (Esc) Manage submitted jobs in the system print queue Gönderilen işleri sistem yazdırma kuyruğunda yönetin - Cancel any current edits (Esc) Mevcut düzenlemeleri iptal et (Esc) + Cancel any current edits (Esc) Geçerli düzenlemeleri iptal et (Esc) Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc) Cancel transform (Esc) Dönüşümü iptal et (Esc) Cancel warp (Esc) Çözgüyü iptal et (Esc) - Canvas Kanvas - Canvas extension color: Kanvas uzatma rengi: + Canvas Tuval + Canvas extension color: Tuval uzatma rengi: Capture Yakala Capture the visibility, position and style of every layer with + below, then switch between versions. Aşağıdaki + ile her katmanın görünürlüğünü, konumunu ve stilini yakalayın, ardından sürümler arasında geçiş yapın. - Catch-up on Stroke End Strok Sonunda Yakalama + Catch-up on Stroke End Vuruş Sonunda Yakalama Center Merkez Center Crop Marks Orta Kırpma İşaretleri Center text Metni ortala @@ -900,33 +901,33 @@ Change All Tümünü Değiştir Change Screen Mode (F) Ekran Modunu Değiştir (F) Change To: Şuna Değiştir: - Channel Mixer Kanal Mikseri + Channel Mixer Kanal Karıştırıcı Channel: Kanal: - Check All Layers Tüm Katmanları Kontrol Et - Check Spelling Yazımı Kontrol Et + Check All Layers Tüm Katmanları Denetle + Check Spelling Yazımı Denetle Clarity Netlik Clear All Tümünü Temizle Clear Override Geçersiz Kılmayı Temizle Clear Samples Örnekleri Temizle - Click sets foreground · right-click sets background Ön planı ayarlar'a tıklayın · arka planı sağ tıklayın + Click sets foreground · right-click sets background Tıklama ön planı, sağ tıklama arka planı ayarlar Click the first point or press {key} to close · Esc cancels Kapatmak için ilk noktayı tıklayın veya {key} tuşuna basın · Esc iptal eder - Click to add a point · drag off or {key}-click or Delete to remove Nokta eklemek için tıklayın · sürükleyin veya {key} tuşuna basıp tıklayın veya kaldırmak için Sil + Click to add a point · drag off or {key}-click or Delete to remove Nokta eklemek için tıklayın · kaldırmak için dışarı sürükleyin, {key}-tıklayın veya Delete'e basın Click to apply, {key}-click to clear overrides, double-click for Style Options Uygulamak için tıklayın, geçersiz kılmaları temizlemek için {key}-tıklayın, Stil Seçenekleri için çift tıklayın Click to edit the gradient Degradeyi düzenlemek için tıklayın - Click to restore the current colour Mevcut rengi geri yüklemek için tıklayın - Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Yakınlaştırmak için tıklayın · {key}-uzaklaştırmak için tıklayın · yakınlaştırmak/uzaklaştırmak için sağa/sola sürükleyin + Click to restore the current colour Geçerli rengi geri yüklemek için tıklayın + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Yakınlaştırmak için tıklayın · uzaklaştırmak için {key}-tıklayın · yakınlaştırmak/uzaklaştırmak için sağa/sola sürükleyin Click with the Note tool to add a note. Not eklemek için Not aracıyla tıklayın. Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · İlk noktaya tıklayın: kapat · {key} bitiş · Esc iptal Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · {alt}-sürükleyin: tutamacı kırın · İlk noktayı tıklayın veya sürükleyin: kapat · {key} bitiş · Esc iptal - Clip to layer below Aşağıdaki katmana klip + Clip to layer below Aşağıdaki katmana kırp Clipped Kırpılmış Close Style Options Stil Seçeneklerini Kapat - Cmd Komut + Cmd Cmd Coated CMYK Kaplamalı CMYK Color & Swatches Renk ve Renk Örnekleri Color Balance Renk Dengesi - Color Detail Renk Detayı - Color Grading Renk Derecelendirmesi + Color Detail Renk Ayrıntısı + Color Grading Renk Derecelendirme Color Lookup Renk Arama Color Management Renk Yönetimi Color Mixer Renk Karıştırıcı @@ -936,19 +937,20 @@ Color: Renk: Colorize Renklendir Colors Renkler - Commit Perspective Warp ({key}) Perspektif Çarpıtma İşlemi ({key}) - Commit Puppet Warp ({key}) Kukla Çarpıtma İşlemi ({key}) - Commit any current edits ({key}) Mevcut düzenlemeleri tamamlayın ({key}) + Commit Perspective Warp ({key}) Perspektif Çarpıtmayı Uygula ({key}) + Commit Puppet Warp ({key}) Kukla Çarpıtmayı Uygula ({key}) + Commit any current edits ({key}) Geçerli düzenlemeleri tamamlayın ({key}) Commit current crop operation ({key}) Geçerli kırpma işlemini gerçekleştir ({key}) Commit transform ({key}) Dönüşümü kaydet ({key}) - Commit warp ({key}) Çözgü gerçekleştir ({key}) + Commit warp ({key}) Çarpıtmayı onayla ({key}) Constant Sabit - Constrain proportions Oranları sınırla + Constrain proportions Oranları koru Contact Sheet II İletişim Sayfası II Content-Aware İçeriğe Duyarlı Contiguous Bitişik Contrast Kontrast Control: Kontrol: + Convert to sRGB sRGB'ye dönüştür Copies Kopyalar Copper Bakır @@ -957,18 +959,18 @@ Corner Crop Marks Köşe Kırpma İşaretleri Corner radius Köşe yarıçapı Correction Düzeltme - Count Jitter Titreşimi say + Count Jitter Sayım Titreşimi Create Oluştur Create Droplet Damlacık Oluştur Create Texture Doku Oluştur - Create Video Timeline Video Zaman Çizelgesi Oluşturun + Create Video Timeline Video Zaman Çizelgesi Oluştur Create a new count group Yeni bir sayım grubu oluşturun Create a new document or open an existing file. Yeni bir belge oluşturun veya mevcut bir dosyayı açın. Create a new group Yeni bir grup oluştur Create a new layer Yeni bir katman oluştur Create new action Yeni eylem oluştur Create new channel Yeni kanal oluştur - Create new document from current state Mevcut durumdan yeni belge oluştur + Create new document from current state Geçerli durumdan yeni belge oluştur Create new fill or adjustment layer Yeni dolgu veya ayarlama katmanı oluştur Create new gradient Yeni degrade oluştur Create new group Yeni grup oluştur @@ -976,14 +978,14 @@ Create new pattern from the selection Seçimden yeni desen oluştur Create new shape from the current path Geçerli yoldan yeni şekil oluştur Create new snapshot Yeni anlık görüntü oluştur - Create new style (from the selected type) Yeni stil oluştur (seçilen türden) + Create new style (from the selected type) Yeni stil oluştur (seçilen metinden) Create new style from the selected layer Seçilen katmandan yeni stil oluştur Create new tool preset Yeni araç ön ayarı oluştur - Crop Factor Mahsul Faktörü + Crop Factor Kırpma Faktörü Ctrl Ctrl - Current & Below Güncel ve Aşağıda + Current & Below Geçerli ve Altındakiler Current Layer Geçerli Katman - Current Size Mevcut Boyut + Current Size Geçerli Boyut Current Tool Only Yalnızca Geçerli Araç Curve Eğri Curves Eğriler @@ -992,8 +994,8 @@ Cyan Camgöbeği Cyan · Red Camgöbeği · Kırmızı Darken Koyulaştır - Darks Karanlıklar - Data Sets Veri Setleri + Darks Koyu Tonlar + Data Sets Veri Kümeleri Default Varsayılan Default colours (D) Varsayılan renkler (D) Default printer Varsayılan yazıcı @@ -1010,8 +1012,8 @@ Delete Timeline Zaman Çizelgesini Sil Delete Tool Preset Araç Ön Ayarını Sil Delete count group Sayım grubunu sil - Delete current channel Mevcut kanalı sil - Delete current state Mevcut durumu sil + Delete current channel Geçerli kanalı sil + Delete current state Geçerli durumu sil Delete effect layer Efekt katmanını sil Delete layer ‘{name}’? '{name}' katmanı silinsin mi? Delete {count} layers? {names} {count} katman silinsin mi? {names} @@ -1030,17 +1032,17 @@ Density Yoğunluk Density: Yoğunluk: Depth Derinlik - Depth Jitter Derinlik Değişimi + Depth Jitter Derinlik Titreşimi Description Açıklama Deselect All Tümünün Seçimini Kaldır - Detail Detay + Detail Ayrıntı Diamond Gradient Elmas Degrade Difference Fark Diffusion Difüzyon Discard the embedded profile (don't color manage) Gömülü profili atın (renk yönetimi yapmayın) - Discontiguous bitişik değil + Discontiguous Bitişik Değil Discord Discord - Dismiss Reddet + Dismiss Kapat Display P3 Display P3 Display Text Metni Görüntüle Dither Titreşim @@ -1052,18 +1054,19 @@ Done Bitti Dots Noktalar Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; silmek için sağ tıklayın. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için görüntü üzerinde sürükleyin; yatay/dikey için Shift; silmek için sağ tıklayın. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; değiştirmek veya silmek için bir kısıtlamaya sağ tıklayın. Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin - Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapayı seçin · Sürükleyin: çapayı, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapa noktasını seçin · Sürükleyin: çapa noktasını, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun. Drop an image or PSD anywhere to open it. Açmak için bir görüntüyü veya PSD'yi herhangi bir yere bırakın. Native file drag-and-drop is unavailable Yerel dosya sürükle ve bırak özelliği kullanılamıyor - Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosyayı Kullan › Aç veya resmi dosya yöneticinize kopyalayıp {paste} ile yapıştırın. + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosya › Aç'ı kullanın veya görüntüyü dosya yöneticinizde kopyalayıp {paste} ile yapıştırın. To drop files, start PhotoCraft under XWayland: `{command}` Dosyaları bırakmak için XWayland altında PhotoCraft'ı başlatın: `{command}` - Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Veya Tercihler › Performans › Linux görüntüleme sunucusunu X11: PhotoCraft olarak ayarlayın, ardından her zaman XWayland altında başlar. + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Veya Tercihler › Performans › Linux görüntüleme sunucusunu X11 olarak ayarlayın: PhotoCraft bundan sonra her zaman XWayland altında başlar. Edit › Paste Düzenle › Yapıştır Duplicate Layers… Katmanları Çoğalt… - Duplicate style Yinelenen stil + Duplicate style Stili çoğalt Edit Toolbar… Araç Çubuğunu Düzenle… Use File › Open, or paste a copied image with {paste}. Dosya › Aç'ı kullanın veya kopyalanan görüntüyü {paste} ile yapıştırın. Edit in Quick Mask Mode (Q) Hızlı Maske Modunda Düzenleme (Q) @@ -1071,12 +1074,12 @@ Effects Efektler Embed Color Profile Renk Profilini Göm Embedded Profile Mismatch Gömülü Profil Uyuşmazlığı - Enable airbrush-style build-up effects Airbrush stili oluşturma efektlerini etkinleştirin + Enable airbrush-style build-up effects Airbrush tarzı birikim efektlerini etkinleştir Enhance Edge Kenarı Geliştir Entire Font Yazı Tipinin Tamamı - Exact tam + Exact Tam Expansion: Genişleme: - Export Presets Ön Ayarları Dışa Aktarma + Export Presets Ön Ayarları Dışa Aktar Export selected measurements (CSV) Seçilen ölçümleri dışa aktar (CSV) Exposure Pozlama Exposure and Gamma Pozlama ve Gama @@ -1085,52 +1088,52 @@ Factor: Faktör: Faux Bold Sahte Kalın Faux Italic Sahte İtalik - Feather Tüy - Fewer Points Daha Az Puan + Feather Geçiş Yumuşatma + Fewer Points Daha Az Nokta File Settings Dosya Ayarları Fill Doldur - Fill: Doldurun: + Fill: Dolgu: Filter for adjustment layers Ayarlama katmanları için filtre Filter for pixel layers Piksel katmanları için filtre Filter for shape layers Şekil katmanları için filtre Filter for smart objects Akıllı nesneler için filtre - Filter for type layers Yazım katmanları için filtre + Filter for type layers Metin katmanları için filtre Filter: Filtre: Finger Painting Parmak Boyama - Fisheye Balıkgözü - Fit Uygun + Fisheye Balık Gözü + Fit Sığdır Fit Screen Ekrana Sığdır - Fill Screen Doldurma Ekranı + Fill Screen Ekranı Doldur Fit in View Görünüme Sığdır Fixed Sabit Fixed Ratio Sabit Oran Fixed Size Sabit Boyut Flip Çevir Flip X X'i Çevir - Flip X Jitter X Değişimini Çevir - Flip Y Y Çevirme - Flip Y Jitter Çevir Y Titreşimi + Flip X Jitter X Çevirme Titreşimi + Flip Y Y'yi Çevir + Flip Y Jitter Y Çevirme Titreşimi Flow Akış - Flow Jitter Akış Değişimi + Flow Jitter Akış Titreşimi Focal Length Odak Uzunluğu Font Yazı tipi Font Size: Yazı Tipi Boyutu: Font size Yazı tipi boyutu Foreground Ön plan Foreground to Background Ön Plandan Arka Plana - Foreground/Background Jitter Ön Plan/Arka Plan Değişimi + Foreground/Background Jitter Ön Plan/Arka Plan Titreşimi Format Biçim - Frame from Layers… Katmanlardan Çerçeve… - Free Ücretsiz + Frame from Layers… Katmanlardan Kare… + Free Serbest Full Spherical Tam Küresel Fuzziness: Bulanıklık: GPU GPU Gamma Correction Gama Düzeltmesi Gamma: Gama: - Global Küresel - Glow Color Parıltılı Renk - Gradient Map Gradyan Haritası - Grain Tahıl + Global Genel + Glow Color Işıma Rengi + Gradient Map Degrade Eşlemesi + Grain Tane Gray Gri Gray Gamma 2.2 Gri Gama 2.2 Green Yeşil @@ -1141,28 +1144,28 @@ Height: Yükseklik: Hide Auto Slices Otomatik Dilimleri Gizle Hide Properties Özellikleri Gizle - Highlight Compression Sıkıştırmayı Vurgula - Highlight Priority Önceliği Vurgula + Highlight Compression Parlak Ton Sıkıştırma + Highlight Priority Parlak Ton Önceliği Highlights Öne Çıkanlar Home Ana sayfa Horizontal scale Yatay ölçek - Hue Renk Tonu - Hue Jitter Ton Değişimi + Hue Ton + Hue Jitter Renk Tonu Titreşimi Hue/Saturation Ton/Doygunluk Hyphenate Tireleme Ignore Yoksay Ignore All Tümünü Yoksay - Image Size Resim Boyutu + Image Size Görüntü Boyutu Import Brushes… Fırçaları İçe Aktar… Import brushes (.abr) Fırçaları içe aktar (.abr) Import Presets Ön Ayarları İçe Aktar - Imported İthal - Indent First Önce Girintiyi Girin + Imported İçe Aktarıldı + Indent First İlk Satır Girintisi Indent Left Sola Girinti Indent Right Sağa Girinti Indent first line İlk satıra girinti koy Indent left margin Sol kenar boşluğunu girintile - Indent right margin Sağ kenar boşluğuna girinti + Indent right margin Sağ kenar boşluğunu girintile Input: Giriş: Inside İçeride Interlaced Taramalı @@ -1170,12 +1173,12 @@ Intersect with selection ({key}) Seçimle kesiştir ({key}) Invert All Tümünü Ters Çevir Invert has no settings. Tersine çevirmenin hiçbir ayarı yoktur. - Italic italik + Italic İtalik Join us on Discord Discord'da bize katılın - Justify all Hepsini gerekçelendir - Justify last centered Son ortayı iki yana yasla - Justify last left Son sola yasla - Justify last right Son sağa yasla + Justify all Tümünü iki yana yasla + Justify last centered Son satır ortalı, iki yana yasla + Justify last left Son satır sola yaslı, iki yana yasla + Justify last right Son satır sağa yaslı, iki yana yasla Kerning Karakter Aralığı Keyboard Shortcuts Klavye Kısayolları Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler @@ -1183,40 +1186,40 @@ Label Size Etiket Boyutu Labels Etiketler Lanczos (sharp) Lanczos (keskin) - Landscape Manzara + Landscape Yatay Last Document State Son Belge Durumu Layout Düzen - Leading Lider + Leading Satır Aralığı Leading (set to the font size × auto-leading when Auto) Satır Aralığı (Otomatik olduğunda yazı tipi boyutu × otomatik satır aralığına ayarlanır) Leave as is (don't color manage) Olduğu gibi bırakın (renk yönetimi yapmayın) Left align text Metni sola hizala Length: Uzunluk: - Level Seviye - Levels Seviyeler + Level Düzey + Levels Düzeyler Lighten Açıklaştır - Lightness hafiflik - Lights Işıklar + Lightness Açıklık + Lights Açık Tonlar Limits: Sınırlar: Linear Gradient Doğrusal Degrade - Link layers Katmanları bağlayın - Link width and height Bağlantı genişliği ve yüksekliği + Link layers Katmanları bağla + Link width and height Genişliği ve yüksekliği bağla Liquify is not open Sıvılaştırma açık değil Load Yükle - Load 3D LUT… 3D LUT'u yükleyin… + Load 3D LUT… 3D LUT'u Yükle… Load channel as selection Kanalı seçim olarak yükle Load… Yükle… - Localized Color Clusters Yerelleştirilmiş Renk Kümeleri + Localized Color Clusters Yerel Renk Kümeleri Lock Kilit Lock all Tümünü kilitle - Lock image pixels Resim piksellerini kilitle + Lock image pixels Görüntü piksellerini kilitle Lock layer Katmanı kilitle - Lock position Kilit konumu - Lock transparent pixels Şeffaf pikselleri kilitle + Lock position Konumu kilitle + Lock transparent pixels Saydam pikselleri kilitle Lock: Kilit: Logical Length: Mantıksal Uzunluk: Logical Units: Mantıksal Birimler: Luminance parlaklık - Luminance Detail Parlaklık Detayı + Luminance Detail Lüminans Ayrıntısı Magenta Macenta Magenta · Green Macenta · Yeşil Maintain aspect ratio En boy oranını koru @@ -1224,23 +1227,23 @@ Mask All Tümünü Maskele Mask Options Maske Seçenekleri Masking Maskeleme - Master Usta + Master Ana Matte Mat Median: Medyan: Menus Menüler - Mesh Size Örgü Boyutu + Mesh Size Ağ Boyutu Metadata Meta veriler Metrics Metrikler Midpoint Orta nokta Midtones Orta tonlar - Midtones from: Orta tonlar: - Midtones to: Orta tonlar: + Midtones from: Orta tonlar (başlangıç): + Midtones to: Orta tonlar (bitiş): Minimum Depth Minimum Derinlik Minimum Diameter Minimum Çap Minimum Roundness Minimum Yuvarlaklık Mode: Mod: Monochrome Tek renkli - More Points Daha Fazla Puan + More Points Daha Fazla Nokta Move effect layer down Efekt katmanını aşağı taşı Move effect layer up Efekt katmanını yukarı taşı Move to Şuraya taşı: @@ -1263,16 +1266,16 @@ No matching commands Eşleşen komut yok No presets for the current tool. Geçerli araç için ön ayar yok. No presets of this type. Bu türden ön ayar yok. - No properties Mülk yok + No properties Özellik yok No tool presets. Araç ön ayarı yok. Noise Reduction Gürültü Azaltma Normal Normal Not in Dictionary: Sözlükte Yok: OK Tamam - Offset Ofset + Offset Kaydırma Only Web Colors Yalnızca Web Renkleri Opacity Opaklık - Opacity Jitter Opaklık Değişimi + Opacity Jitter Opaklık Titreşimi Opacity: Opaklık: Open a document to define variables. Değişkenleri tanımlamak için bir belge açın. Open a document to select a colour range. Bir renk aralığı seçmek için bir belge açın. @@ -1282,7 +1285,7 @@ Option-click to define a source point to clone from Kopyalanacak bir kaynak noktası tanımlamak için Option tuşunu basılı tutarak tıklayın Original Orijinal Output Channel: Çıkış Kanalı: - Output Levels: Çıkış Seviyeleri: + Output Levels: Çıkış Düzeyleri: Output: Çıkış: Outside Dışarıda Overlay Color Kaplama Rengi @@ -1291,7 +1294,9 @@ Override Pressure Basıncı Geçersiz Kıl Override Rotation Döndürmeyi Geçersiz Kıl Override Tilt Eğimi Geçersiz Kıl - PRESET DETAILS ÖN AYAR DETAYLARI + PRESET DETAILS ÖN AYAR AYRINTILARI + Override Tilt X X Eğimini Geçersiz Kıl + Override Tilt Y Y Eğimini Geçersiz Kıl PSD PSD Paint Overlay Boya Kaplaması Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panel konumları çalışma alanına kaydedilir. Klavye kısayolları, menüler ve araç çubuğu isteğe bağlıdır. @@ -1301,22 +1306,22 @@ Pattern Size Desen Boyutu Pause Duraklat Percent Yüzde - Perceptual algısal + Perceptual Algısal Perspective Warp is not active Perspektif Çarpıtma etkin değil Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektif Çarpıtma: görüntünün perspektifi boyunca düzlemler çizin, ardından Çarpıtma'ya geçin Photo Filter Fotoğraf Filtresi PhotoCraft PhotoCraft PhotoCraft Manages Colors PhotoCraft Renkleri Yönetir - PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — Rust'ta yazılmış açık kaynaklı, yerel bir resim düzenleyici. + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — Rust'ta yazılmış açık kaynaklı, yerel bir görüntü düzenleyici. Pin Depth: Pim Derinliği: Pixel Length: Piksel Uzunluğu: Pixels/Centimeter Piksel/Santimetre Pixels/Inch Piksel/İnç Pixels: Piksel: - Play Oyna + Play Oynat Play selection Seçimi oynat - Portrait Portre - Position Pozisyon + Portrait Dikey + Position Konum Position and Size Konum ve Boyut Posterize Posterleştir Preserve Details Ayrıntıları Koru @@ -1328,10 +1333,10 @@ Press keys… Tuşlara basın… Pressure Basınç Pressure for Opacity Opaklık Basıncı - Pressure for Size Boyuta Göre Basınç + Pressure for Size Boyut Basıncı Prevent auto-nesting in and out of Artboards and Frames Çalışma Yüzeyleri ve Çerçevelerin içine ve dışına otomatik yerleştirmeyi önleyin Preview Önizleme - Previous frame Önceki çerçeve + Previous frame Önceki kare Previous note Önceki not Print Yazdır Printer Yazıcı @@ -1339,22 +1344,22 @@ Printer Setup Yazıcı Kurulumu Printing Marks Baskı İşaretleri Progressive Aşamalı - Promote Tanıt - Protect Detail Detayı Koruyun - Protect Foreground Color Ön Plan Rengini Koruyun - Protect Tones Tonları Koruyun + Promote Yükselt + Protect Detail Ayrıntıyı Koru + Protect Foreground Color Ön Plan Rengini Koru + Protect Tones Tonları Koru Proximity Match Yakınlık Eşleşmesi - Pulled String Mode Çekilmiş Dize Modu + Pulled String Mode Çekilen İp Modu Puppet Warp is not active Kukla Çarpıtma etkin değil - Purity saflık + Purity Saflık Quality Kalite Quick Actions Hızlı Eylemler Quick Export As PNG PNG Olarak Hızlı Dışa Aktarma - Radial Gradient Radyal Gradyan + Radial Gradient Radyal Degrade Radius Yarıçap Radius: Yarıçap: Range: Aralık: - Rate Oran + Rate Hız Recently Used Son Kullanılanlar Reconstruct Yeniden inşa et Record ● a sequence of edits, then play ▶ it on any document. Bir dizi düzenlemeyi kaydedin ● ve ardından herhangi bir belgede oynatın. @@ -1362,14 +1367,14 @@ Redefine style by current selection Stili geçerli seçime göre yeniden tanımlayın Refine Hassaslaştır Reflected Gradient Yansıyan Degrade - Registration Marks Tescil İşaretleri - Regular Düzenli - Relative akraba + Registration Marks Kayıt İşaretleri + Regular Normal + Relative Bağıl Relative Colorimetric Bağıl Kolorimetrik Remove Kaldır - Remove All Pins Tüm Pinleri Kaldır + Remove All Pins Tüm Pimleri Kaldır Remove Background Arka Planı Kaldır - Remove Planes Uçakları Kaldır + Remove Planes Düzlemleri Kaldır Remove stop Durağı kaldır Remove variable Değişkeni kaldır Rename Yeniden adlandır @@ -1377,16 +1382,16 @@ Rename Layer… Katmanı Yeniden Adlandır… Rename Tool Preset… Araç Ön Ayarını Yeniden Adlandır… Rename… Yeniden adlandır… - Resample Yeniden örnekle + Resample Yeniden Örnekle Reset All to Defaults Tümünü Varsayılanlara Sıfırla Import Shortcuts… Kısayolları İçe Aktar… Load a keyboard shortcut set saved as .kys .kys olarak kaydedilen bir klavye kısayolu kümesi yükleyin - Imported {n} shortcuts from {file}. Click OK to keep them. {n} kısayolları {file} adresinden içe aktarıldı. Bunları saklamak için Tamam'ı tıklayın. - {n} commands are not in PhotoCraft. {n} komutları PhotoCraft'ta değil. - Keyboard shortcuts imported İçe aktarılan klavye kısayolları - Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. {set} klavye kısayol grubunuz burada kullanılıyor: {n} kısayolları varsayılanlardan farklı. Düzenle › Klavye Kısayolları bunları gösterir; Tümünü Varsayılanlara Sıfırla bu işlemi geri alır. + Imported {n} shortcuts from {file}. Click OK to keep them. {n} kısayol {file} dosyasından içe aktarıldı. Saklamak için Tamam'a tıklayın. + {n} commands are not in PhotoCraft. {n} komut PhotoCraft'ta yok. + Keyboard shortcuts imported Klavye kısayolları içe aktarıldı + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. {set} klavye kısayol setiniz burada kullanılıyor: {n} kısayol varsayılanlardan farklı. Düzenle › Klavye Kısayolları bunları gösterir; Tümünü Varsayılanlara Sıfırla bu işlemi geri alır. Import keyboard shortcuts? Klavye kısayolları içe aktarılsın mı? - PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft, {set} klavye kısayol setinizi bu bilgisayardaki başka bir resim düzenleyicide buldu: {n} kısayolları PhotoCraft'ınkilerden farklı. İçe aktarılsın mı? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft, bu bilgisayardaki başka bir görüntü düzenleyicide {set} klavye kısayol setinizi buldu: {n} kısayol PhotoCraft'tan farklı. İçe aktarılsın mı? Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Düzenle › Klavye Kısayolları › Tümünü Varsayılanlara Sıfırla, içe aktarma işlemini geri alır. Don't Import İçe Aktarma The keyboard shortcut set was not imported: {error} Klavye kısayol seti içe aktarılmadı: {error} @@ -1404,62 +1409,63 @@ Rotation Döndürme Roughness Pürüzlülük Roundness Yuvarlaklık - Roundness Jitter Yuvarlaklık Değişimi + Roundness Jitter Yuvarlaklık Titreşimi Rulers & Grids Cetveller ve Izgaralar Sample All Layers Tüm Katmanları Örnekle Sample: Örnek: Sampling: Background Swatch Örnekleme: Arka Plan Renk Örneği Sampling: Continuous Örnekleme: Sürekli Sampling: Once Örnekleme: Bir kez - Saturate Doygunluk + Saturate Doygunlaştır Saturation Doygunluk - Saturation Jitter Doygunluk Değişimi - Save HTML and Images HTML ve Görselleri Kaydet + Saturation Jitter Doygunluk Titreşimi + Save HTML and Images HTML ve Görüntüleri Kaydet Save as PDF PDF olarak kaydet Save selection as channel Seçimi kanal olarak kaydet Save… Kaydet… - Scale to Fit Media Medyaya Sığacak Şekilde Ölçeklendirin + Scale to Fit Media Ortama Sığacak Şekilde Ölçekle Scatter Dağılım - Scratch disks Kazıma diskleri + Scratch disks Karalama diskleri Search Ara Search commands Arama komutları Search commands, tools and panels… Komutları, araçları ve panelleri arayın… - Search fonts Yazı tiplerini arayın + Search fonts Yazı tipi ara Select All Tümünü Seç - Select Subject Konuyu Seçin - Select a type layer or click into text to insert glyphs Bir yazım katmanı seçin veya glif eklemek için metne tıklayın + Select Subject Konuyu Seç + Select a type layer or click into text to insert glyphs Bir metin katmanı seçin veya glif eklemek için metne tıklayın Select text to apply a character style Karakter stili uygulamak için metni seçin - Select: Seçin: + Select: Seç: Selections Seçimler - Selective seçici + Selective Seçici Selective Color Seçici Renk - Send the selected pin backward Seçilen pini geriye gönder - Set additional smoothing options Ek yumuşatma seçeneklerini ayarlayın + Send the selected pin backward Seçilen pimi geriye gönder + Set additional smoothing options Ek yumuşatma seçeneklerini ayarla Set background color Arka plan rengini ayarla Set foreground color Ön plan rengini ayarla - Set painting symmetry options Boyama simetrisi seçeneklerini ayarlama + Set painting symmetry options Boyama simetrisi seçeneklerini ayarla Symmetry Off Simetri Kapalı Set shape fill type Şekil doldurma türünü ayarla - Set shape stroke type Şekil kontur türünü ayarlama - Set the text color Metin rengini ayarlama + Set shape stroke type Şekil kontur türünü ayarla + Set the text color Metin rengini ayarla + Create warped text Çarpık metin oluştur Shadows Gölgeler Shape: Şekil: - Sharpening Bileme + Sharpening Keskinleştirme Shift Shift Shortcut removed. Kısayol kaldırıldı. Show All Menu Items Tüm Menü Öğelerini Göster Show Backdrop Arka Planı Göster Show Guides Kılavuzları Göster Show Mask Maskeyi Göster - Show Mesh Mesh'i Göster + Show Mesh Ağı Göster Show Overlay Kaplamayı Göster Show Transform Controls Dönüştürme Kontrollerini Göster - Show or hide the Notes panel Notlar panelini gösterme veya gizleme + Show or hide the Notes panel Notlar panelini göster veya gizle Show: Göster: - Sides Taraflar - Sides: Taraflar: + Sides Kenarlar + Sides: Kenar sayısı: Size Boyut - Size Jitter Boyut Değişimi + Size Jitter Boyut Titreşimi Size: Boyut: Slice Options… Dilim Seçenekleri… Slices From Guides Kılavuzlardan Dilimler @@ -1473,33 +1479,33 @@ Split: Bölünmüş: Star ratio Yıldız oranı Stop playing/recording Oynatmayı/kaydetmeyi durdur - Straighten Düzelt - Straighten Layer Katmanı Düzleştir + Straighten Doğrult + Straighten Layer Katmanı Doğrult Strength: Güç: Strikethrough Üstü çizili Stroke Kontur - Stroke Catch-up İnme Yakalama - Stroke: İnme: + Stroke Catch-up Vuruş Yakalama + Stroke: Kontur: Style Stil Style Options Stil Seçenekleri Style Options… Stil Seçenekleri… - Style: Normal Tarz: Normal - Subtract from Sample Örnekten Çıkart + Style: Normal Stil: Normal + Subtract from Sample Örnekten Çıkar Subtract from selection ({key}) Seçimden çıkar ({key}) Suggestions: Öneriler: Swap colours (X) Renkleri değiştir (X) Swaps height and width Yüksekliği ve genişliği değiştirir - Switch between free transform and warp modes Serbest dönüştürme ve çarpıtma modları arasında geçiş yapın + Switch between free transform and warp modes Serbest dönüştürme ve bükme modları arasında geçiş yapın Switch theme Temayı değiştir Temperature Sıcaklık - Tetrahedral dörtyüzlü + Tetrahedral Dörtyüzlü Text Position: Metin Konumu: Texture Doku - Texture Each Tip Her Ucu Doku + Texture Each Tip Her Uca Doku Uygula Threshold Eşik - Threshold Level Eşik Seviyesi + Threshold Level Eşik Düzeyi Thumbnail size (smallest: list) Küçük resim boyutu (en küçük: liste) - Tilt X X'i yatır + Tilt X X Eğimi Tilt Y Y Eğimi Tint Renk tonu Toggle count group visibility Sayım grubu görünürlüğünü değiştir @@ -1510,29 +1516,30 @@ Toolbar Araç Çubuğu Top Üst Total: Toplam: - Tracking Takip - Tracking (1/1000 em) Takip (1/1000 em) - Transparency Şeffaflık - Transparent Şeffaf + Tracking İzleme + Tracking (1/1000 em) İzleme (1/1000 em) + Transparency Saydamlık + Transparent Saydam Trilinear Üç çizgili - Type Styles Tip Stilleri + Type Styles Metin Stilleri Type: Tür: Underline Altı çizili - Untitled İsimsiz - Untitled-1 İsimsiz-1 + Untitled Adsız + Untitled-1 Adsız-1 Update layer comp (visibility, position and appearance) Katman kompozisyonunu güncelleyin (görünürlük, konum ve görünüm) Use Default Varsayılanı Kullan Use Legacy Eskiyi Kullan Use Measurement Scale Ölçüm Ölçeğini Kullan + Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. Vertical scale Dikey ölçek Vibrance Canlılık - View Options Seçenekleri Görüntüle - Vignetting Kenar ayrıntısı kaybı + View Options Görünüm Seçenekleri + Vignetting Vinyet Violet, Orange Menekşe, Turuncu Visibility Görünürlük Visible Görünür - Warp: Çözgü: + Warp: Çarpıtma: Web Snap Web Snap Weight Ağırlık Weight: Ağırlık: @@ -1549,12 +1556,12 @@ Zoom in Yakınlaştır Zoom out Uzaklaştır command or shortcut komut veya kısayol - current akım - image file path resim dosyası yolu + current geçerli + image file path görüntü dosyası yolu modified değiştirilmiş - name isim + name ad new yeni - {name} options {name} seçenekler + {name} options {name} seçenekleri ↑↓ navigate {key} run esc close ↑↓ gezin {key} çalıştır esc kapat Version {version} Sürüm {version} New Document Yeni Belge @@ -1567,15 +1574,15 @@ Command Komut Adjustment layers have nothing to transform Ayarlama katmanlarının dönüştürülecek hiçbir şeyi yok Altitude Rakım - Android 1080p Android1080p + Android 1080p Android 1080p Apple Watch 45mm Apple Watch 45mm - Aqua Su + Aqua Turkuaz Art & Illustration Sanat ve İllüstrasyon ArtCraft ArtCraft Background Eraser Tool Arka Plan Silgisi Aracı - Bevel & Emboss Eğim Ver ve Kabart - Blend Mode Karışım Modu - Blues Blues + Bevel & Emboss Eğim ve Kabartma + Blend Mode Karıştırma Modu + Blues Maviler Blur Tool Bulanıklık Aracı Bold Kalın Brush Fırça @@ -1583,40 +1590,40 @@ Brush Pose Fırça Duruşu Brush Tip Shape Fırça Ucu Şekli Brush Tool Fırça Aracı - Build-up İnşaat - Burn Tool Koyulaştırma Aracı + Build-up Birikim + Burn Tool Yakma Aracı Centimeters Santimetre - Choke Şok + Choke Daraltma Classic Klasik Click to add text Metin eklemek için tıklayın Click to fill similar colours Benzer renkleri doldurmak için tıklayın Clone Stamp Tool Klonlama Damgası Aracı - Color Burn Renk Yanması + Color Burn Renk Yakma Color Dodge Renk Soldurma Color Dynamics Renk Dinamiği Color Overlay Renk Kaplaması Comic Book Çizgi Roman - Conform Uygun - Content-Aware Fill needs a selection on a pixel layer İçeriğe Uygun Dolgu, piksel katmanında seçim yapılmasını gerektirir + Conform Uyarla + Content-Aware Fill needs a selection on a pixel layer İçeriğe Duyarlı Dolgu, piksel katmanında seçim yapılmasını gerektirir Could not transform: the layer is empty Dönüştürülemedi: katman boş Could not use Perspective Warp: the layer is empty Perspektif Çarpıtma kullanılamadı: katman boş - Could not use Puppet Warp: no opaque pixels Puppet Warp kullanılamadı: opak piksel yok - Could not use Puppet Warp: the layer is empty Puppet Warp kullanılamadı: katman boş + Could not use Puppet Warp: no opaque pixels Kukla Çarpıtma kullanılamadı: opak piksel yok + Could not use Puppet Warp: the layer is empty Kukla Çarpıtma kullanılamadı: katman boş Crop Tool Kırpma Aracı Crosswise Çapraz olarak - Current Tool Güncel Araç + Current Tool Geçerli Araç Custom Shape Tool Özel Şekil Aracı Cyans Camgöbeği Default PhotoCraft Size Varsayılan PhotoCraft Boyutu Delete Workspace Çalışma Alanını Sil Diamond Elmas - Dimensions: Boyutlar: + Dimensions: Ölçüler: Direction Yön Distance Uzaklık - Document Dimensions Belge Boyutları + Document Dimensions Belge Ölçüleri Document Profile Belge Profili Document Sizes Belge Boyutları - Dodge Tool Açıklaştırma Aracı + Dodge Tool Soldurma Aracı Down Aşağı Drag to draw a gradient Degrade çizmek için sürükleyin Drag to move the active layer Etkin katmanı taşımak için sürükleyin @@ -1627,9 +1634,9 @@ Efficiency Verimlilik Efficiency: 100% Verimlilik: %100 Ellipse Tool Elips Aracı - Elliptical Marquee Tool Eliptik İşaretleme Aracı - Single Row Marquee Tool Tek Satır İşaretleme Aracı - Single Column Marquee Tool Tek Sütun İşaretleme Aracı + Elliptical Marquee Tool Eliptik Seçim Çerçevesi Aracı + Single Row Marquee Tool Tek Satır Seçim Çerçevesi Aracı + Single Column Marquee Tool Tek Sütun Seçim Çerçevesi Aracı Emboss Kabartma Eraser Silgi Eraser Tool Silgi Aracı @@ -1639,10 +1646,10 @@ Fade Solmaya Fill Opacity Dolgu Opaklığı Film & Video Film ve Video - From Gönderen + From Şuradan General Genel GitHub GitHub - Gradient Overlay Degrade Yerleşimi + Gradient Overlay Degrade Kaplaması Gradient Tool Degrade Aracı Greens Yeşiller Hand Tool El Aracı @@ -1654,7 +1661,7 @@ Horizontal Type Tool Yatay Metin Aracı Horizontal Type Mask Tool Yatay Metin Maskesi Aracı Vertical Type Mask Tool Dikey Metin Maskesi Aracı - Image Size: Resim Boyutu: + Image Size: Görüntü Boyutu: Inches İnç Indexed Dizine eklendi Initial Direction Başlangıç Yönü @@ -1668,21 +1675,21 @@ Lasso Kement Lasso Tool Kement Aracı Layer Count Katman Sayısı - Layer Knocks Out Drop Shadow Katman Alt Gölgeyi Ortadan Kaldırır - Legal Yasal - Letter Mektup - Light Işık + Layer Knocks Out Drop Shadow Katman Düşen Gölgeyi Deler + Legal Legal + Letter Letter + Light Hafif Line Tool Çizgi Aracı Linear Doğrusal - Linear Burn Doğrusal Yanma + Linear Burn Doğrusal Yakma Linear Height Doğrusal Yükseklik Link with Layer Katmanla Bağlantı - Lorem Ipsum Lorem İpsum + Lorem Ipsum Lorem Ipsum Magentas Macentalar Magic Eraser Tool Sihirli Silgi Aracı Magic Wand Tool Sihirli Değnek Aracı Marquee Seçim Çerçevesi - Mean: Anlamı: + Mean: Ortalama: Measurement Scale Ölçüm Ölçeği Measurement Scale Marker Ölçüm Ölçeği İşaretleyicisi Millimeters Milimetre @@ -1702,17 +1709,17 @@ Original Ratio Orijinal Oran Out Of Gamut Gamut Dışı Outer Bevel Dış Eğim - Outer Glow Dış Parıltı + Outer Glow Dış Işıma Overlay Kaplama Paint Bucket Tool Boya Kovası Aracı - Paints from the document's opening state Belgenin açılış durumundaki boyalar + Paints from the document's opening state Belgenin açılış durumundan boyar Path Selection Tool Yol Seçimi Aracı Direct Selection Tool Doğrudan Seçim Aracı - Add Anchor Point Tool Bağlantı Noktası Ekleme Aracı - Delete Anchor Point Tool Bağlantı Noktası Silme Aracı - Click a segment to add an anchor point · {alt}-click an anchor to delete it Bağlantı noktası eklemek için bir segmenti tıklayın · {alt}-silmek için bir bağlantıyı tıklayın - Click an anchor point to delete it · {alt}-click a segment to add one Silmek için bir bağlantı noktasını tıklayın · {alt}-bir parça eklemek için bir parçayı tıklayın - Pattern Overlay Desen Yerleşimi + Add Anchor Point Tool Çapa Noktası Ekleme Aracı + Delete Anchor Point Tool Çapa Noktası Silme Aracı + Click a segment to add an anchor point · {alt}-click an anchor to delete it Çapa noktası eklemek için bir parçaya tıklayın · silmek için bir çapa noktasına {alt}-tıklayın + Click an anchor point to delete it · {alt}-click a segment to add one Silmek için bir çapa noktasına tıklayın · eklemek için bir parçaya {alt}-tıklayın + Pattern Overlay Desen Kaplaması Pen Pressure Kalem Basıncı Pen Tilt Kalem Eğimi Pen Tool Kalem Aracı @@ -1723,19 +1730,19 @@ Pillow Emboss Yastık Kabartma Pixel Replacement Piksel Değiştirme Pixels Piksel - Points Puanlar + Points Punto Polygon Tool Çokgen Aracı Polygonal Lasso Tool Çokgen Kement Aracı - Portrait, 4 x 6 Portre, 4 x 6 - Portrait, 5 x 7 Portre, 5 x 7 - PostScript (72 points/inch) PostScript (72 nokta/inç) + Portrait, 4 x 6 Dikey, 4 x 6 + Portrait, 5 x 7 Dikey, 5 x 7 + PostScript (72 points/inch) PostScript (72 punto/inç) Postcard Kartpostal Poster Poster Pro (Dark) Pro (Koyu) - Pro (Light) Pro (Hafif) + Pro (Light) Pro (Açık) Pro (Light Gray) Pro (Açık Gri) Pro (Medium Gray) Pro (Orta Gri) - Protect Texture Dokuyu Koruyun + Protect Texture Dokuyu Koru Purple Mor Quick Mask Hızlı Maske Quick Selection Tool Hızlı Seçim Aracı @@ -1745,7 +1752,7 @@ Ratio Oran Recent Son Rectangle Tool Dikdörtgen Aracı - Rectangular Marquee Tool Dikdörtgen İşaretleme Aracı + Rectangular Marquee Tool Dikdörtgen Seçim Çerçevesi Aracı Reds Kırmızılar Reflected Yansıyan Sample: foreground colour Örnek: ön plan rengi @@ -1753,7 +1760,7 @@ Satin Saten Save Pixel Aspect Ratio Piksel En Boy Oranını Kaydet Scattering Saçılma - Scratch Sizes Çizik Boyutları + Scratch Sizes Karalama Boyutları Select Data Points Veri Noktalarını Seçin Selection Seçim SemiBold Yarı Kalın @@ -1772,22 +1779,22 @@ Sponge Tool Sünger Aracı Spot Healing Brush Tool Nokta Düzeltme Fırçası Aracı Spread Yayılma - Square, 12 x 12 Kare, 12x12 - Square, 5 x 5 Kare, 5x5 - Std Dev: Standart Geliştirme: - Studio (Dark) Stüdyo (Karanlık) - Studio (Light) Stüdyo (Işık) + Square, 12 x 12 Kare, 12 x 12 + Square, 5 x 5 Kare, 5 x 5 + Std Dev: Standart Sapma: + Studio (Dark) Stüdyo (Koyu) + Studio (Light) Stüdyo (Açık) Stylus Wheel Kalem Tekerleği Subtract Çıkar Tabloid Tabloid Text Replacement Metin Değiştirme Thin İnce - Traditional (72.27 points/inch) Geleneksel (72,27 nokta/inç) + Traditional (72.27 points/inch) Geleneksel (72,27 punto/inç) Transfer Aktarım Triangle Tool Üçgen Aracı Unsaved changes Kaydedilmemiş değişiklikler Uses the foreground and background colours. Ön plan ve arka plan renklerini kullanır. - Web ağ + Web Web Web Large Web Büyük Web Minimum Web Minimumu Web Most Common Web En Yaygın @@ -1795,20 +1802,20 @@ Wet Edges Islak Kenarlar Yellows Sarılar Zoom Tool Yakınlaştırma Aracı - {name} Properties {name} Özellikler + {name} Properties {name} Özellikleri Revert “{name}” to the last saved version? Your changes will be lost. “{name}” son kaydedilen sürüme döndürülsün mü? Değişiklikleriniz kaybolacak. Do you want to save the changes you made to “{name}” before quitting? Çıkmadan önce “{name}” öğesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? Do you want to save the changes you made to “{name}” before closing? Kapatmadan önce “{name}” üzerinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? - Doc: {flat}/{layered} Doküman: {flat}/{layered} - Scratch: {size} Çizik: {size} + Doc: {flat}/{layered} Belge: {flat}/{layered} + Scratch: {size} Karalama: {size} Untagged {mode} Etiketlenmemiş {mode} Invalid {mode} profile Geçersiz {mode} profili Unnamed {mode} profile Adsız {mode} profili {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) Resolution: {dpi} pixels/inch Çözünürlük: {dpi} piksel/inç - Doc: {w} × {h} px Doküman: {w} × {h} piksel - Frame {current} / {total} Çerçeve {current} / {total} + Doc: {w} × {h} px Belge: {w} × {h} piksel + Frame {current} / {total} Kare {current} / {total} Count: {n} Sayım: {n} BLANK DOCUMENT PRESETS ({n}) BOŞ BELGE ÖN AYARLARI ({n}) Constraints ({n}) Kısıtlamalar ({n}) @@ -1818,7 +1825,7 @@ @plural Group · {n} layer|Group · {n} layers Grup · {n} katman Additional plugins folder Ek eklentiler klasörü Adobe Adobe - Adobe rgb Adobe rgb + Adobe rgb Adobe RGB Allow agent control Aracı kontrolüne izin ver Allow scripts to connect Komut dosyalarının bağlanmasına izin ver Always Her zaman @@ -1841,33 +1848,33 @@ Cache levels Önbellek düzeyleri Cache tile size Önbellek kutucuğu boyutu Camera Raw Defaults Camera Raw Varsayılanları - Canvas border Kanvas kenarlığı - Show canvas dots Kanvas noktalarını göster + Canvas border Tuval kenarlığı + Show canvas dots Tuval noktalarını göster Clip thumbnails to layer bounds Küçük resimleri katman sınırlarına kırp Clip Thumbnails to Layer Bounds Küçük resimleri katman sınırlarına kırp - Canvas color Kanvas rengi - Canvas custom color Kanvas özel rengi + Canvas color Tuval rengi + Canvas custom color Tuval özel rengi Color picker Renk seçici Color space Renk alanı Column width Sütun genişliği Concise Kısa Control port Kontrol portu - Convert to srgb srgb'ye dönüştür + Convert to srgb sRGB'ye dönüştür Cursors İmleçler Custom dark Özel koyu - Custom light Özel ışık - Dark Karanlık + Custom light Özel açık + Dark Koyu Dark gray Koyu gri Dashed lines Kesikli çizgiler Destination Hedef - Detailed Detaylı + Detailed Ayrıntılı Disks Diskler - Display p3 Ekran p3 + Display p3 Display P3 Double click layer mask launches select and mask Çift tıklama katman maskesi seçme ve maskelemeyi başlatır Drop shadow Alt gölge Dynamic color sliders Dinamik renk kaydırıcıları East asian Doğu Asya - Effect cache mb Efekt önbelleği mb + Effect cache mb Efekt önbelleği (MB) Enable flick panning Hızlı kaydırmayı etkinleştir Enable floating document window docking Kayan belge penceresi yerleştirmeyi etkinleştir Enable narrow options bar Dar seçenekler çubuğunu etkinleştir @@ -1877,13 +1884,13 @@ Extra large Ekstra büyük File Handling Dosya İşleme File path Dosya yolu - Fill new type layers with placeholder Yeni yazı katmanlarını yer tutucuyla doldurma + Fill new type layers with placeholder Yeni metin katmanlarını yer tutucuyla doldur Font preview Yazı tipi önizlemesi Full size tip Tam boyutlu uç Gamut warning color Gamut uyarı rengi Gamut warning opacity Gamut uyarı opaklığı - Generator enabled Jeneratör etkin - Gif Gif + Generator enabled Oluşturucu etkin + Gif GIF Glossy parlak Grid color Izgara rengi Grid colors Izgara renkleri @@ -1896,27 +1903,27 @@ Guides, Grid & Slices Kılavuzlar, Izgaralar ve Dilimler Gutter Oluk History Log Geçmiş Günlüğü - History states Tarih durumları + History states Geçmiş durumları Ignore exif profile tag Exif profil etiketini yoksay Image interpolation Görüntü enterpolasyonu - Image previews Resim önizlemeleri + Image previews Görüntü önizlemeleri Integrations Entegrasyonlar Interface Arayüz Jpeg quality JPEG kalitesi - Jpg Jpg + Jpg JPG Language Dil Large tabs Büyük sekmeler Legacy compositing Eski birleştirme Light gray Açık gri Line Hat - Lines çizgiler + Lines Çizgiler Lossless Kayıpsız Lowercase extension Küçük harf uzantısı - Maximize psd compatibility PSD uyumluluğunu en üst düzeye çıkarın + Maximize psd compatibility PSD uyumluluğunu en üst düzeye çıkar Medium gray Orta gri - Memory usage mb Bellek kullanımı mb + Memory usage mb Bellek kullanımı (MB) Low resolution previews Düşük çözünürlüklü önizlemeler - Missing glyph protection Glif koruması eksik + Missing glyph protection Eksik glif koruması Modern Çağdaş Nearest neighbor En yakın komşu Never Asla @@ -1926,36 +1933,37 @@ Open documents as tabs Belgeleri sekmeler halinde aç Overscroll Aşırı kaydırma Performance Performans - Png png + Png PNG Point size Nokta boyutu Precise Hassas Preserve details Ayrıntıları koru Print resolution Yazdırma çözünürlüğü - Pro Profesyonel - Pro light Profesyonel ışık + Pro Pro + Pro light Pro açık Pro light gray Pro açık gri - Pro medium Profesyonel orta - Pro photo Profesyonel fotoğraf + Pro medium Pro orta + Pro photo ProPhoto Quick export format Hızlı dışa aktarma formatı Quick export location Hızlı dışa aktarma konumu Recent file count Son dosya sayısı + LUT folder LUT klasörü Recent files Son dosyalar - Recent fonts En yeni yazı tipleri + Recent fonts Son kullanılan yazı tipleri Recover on launch Başlatıldığında kurtar Remember workspace changes Çalışma alanı değişikliklerini hatırla - Resize image during place Yerleştirme sırasında resmi yeniden boyutlandır - Rotate view with trackpad İzleme dörtgeniyle görünümü döndürme + Resize image during place Yerleştirme sırasında görüntüyü yeniden boyutlandır + Rotate view with trackpad Görünümü dokunmatik yüzeyle döndür Same folder Aynı klasör Save in background Arka planda kaydet - Scratch Disks Kazı Diskleri + Scratch Disks Karalama Diskleri Screen Ekran Screen resolution Ekran çözünürlüğü - Scrubby slider acceleration Temiz kaydırıcı hızlandırması + Scrubby slider acceleration Kaydırıcı sürükleme hızlanması Sessions only Yalnızca oturumlar Sharpen for Şunun için keskinleştir: Show channels in color Kanalları renkli göster Show crosshair in brush tip Fırça ucunda artı işaretini göster - Show extension panels Uzatma panellerini göster + Show extension panels Uzantı panellerini göster Show font names in english İngilizce yazı tipi adlarını göster Show menu colors Menü renklerini göster Show only crosshair while painting Boyama sırasında yalnızca artı işaretini göster @@ -1968,34 +1976,34 @@ Slice color Dilim rengi Smart guide color Akıllı kılavuz rengi Smart quotes Akıllı tırnaklar - Snap vector tools and transforms to pixel grid Vektör araçlarını tutturun ve piksel ızgarasına dönüştürün - Srgb Srgb + Snap vector tools and transforms to pixel grid Vektör araçlarını ve dönüşümleri piksel ızgarasına yapıştır + Srgb sRGB Standard Standart Studio Stüdyo - Studio light Stüdyo ışığı + Studio light Stüdyo açık Subdivisions Alt bölümler System Sistem Text engine Metin motoru Text file Metin dosyası - Tiny minik + Tiny Minik Touch gestures Dokunma hareketleri - Transparency & Gamut Şeffaflık ve Gamut - Type units Tip birimleri - UI font size kullanıcı arayüzü yazı tipi boyutu + Transparency & Gamut Saydamlık ve Gamut + Type units Metin birimleri + UI font size Arayüz yazı tipi boyutu UI scale Kullanıcı arayüzü ölçeği Units & Rulers Birimler ve Cetveller Use additional plugins folder Ek eklentiler klasörünü kullan - Use esc to commit Taahhüt etmek için esc kullanın + Use esc to commit Onaylamak için Esc kullan Use gpu GPU'yu kullan - Use legacy free transform Eski ücretsiz dönüşümü kullanın - Use shift key for tool switch Araç anahtarı için Shift tuşunu kullanın - Vary round brush hardness on hud Hud üzerinde yuvarlak fırça sertliğini değiştirin - Webp Webp - World ready Dünya hazır + Use legacy free transform Eski serbest dönüştürmeyi kullan + Use shift key for tool switch Araç değiştirmek için Shift tuşunu kullan + Vary round brush hardness on hud HUD'da yuvarlak fırça sertliğini değiştir + Webp WebP + World ready Dünya dili uyumlu (World-Ready) Zoom clicked point to center Tıklanan noktayı merkeze yakınlaştır Zoom resizes windows Yakınlaştırma pencereleri yeniden boyutlandırır Zoom with scroll wheel Kaydırma tekerleği ile yakınlaştırma - Zoom with trackpad pinch İzleme dörtgeni tutamıyla yakınlaştırma + Zoom with trackpad pinch Dokunmatik yüzeyde sıkıştırarak yakınlaştır Pass Through Geçirgen Dissolve Eritme Darker Color Daha Koyu Renk @@ -2011,18 +2019,18 @@ Luminosity Parlaklık @plural {n} sample|{n} samples {n} örnek {added}, {removed} subtracted {added}, {removed} çıkarıldı - Adjustment Preview Ayar Önizlemesi + Adjustment Preview Ayarlama Önizlemesi Airbrush: keeps depositing paint while the pointer rests. Airbrush: İşaretçi dururken boyayı bırakmaya devam eder. Angle: Açı: - Blend If Eğer karıştır + Blend If Koşullu Karıştırma Close Tab Group Sekme Grubunu Kapat Collapse Daralt Collapse All Groups Tüm Grupları Daralt Collapse Panel Group Panel Grubunu Daralt Collapse group ({key}-click: all groups) Grubu daralt ({key}-tıklama: tüm gruplar) - Create new brush from the current settings Mevcut ayarlardan yeni fırça oluştur - Create new brush from these settings Bu ayarlardan yeni fırça oluşturun - Update the current brush with these settings Mevcut fırçayı bu ayarlarla güncelleyin + Create new brush from the current settings Geçerli ayarlardan yeni fırça oluştur + Create new brush from these settings Bu ayarlardan yeni fırça oluştur + Update the current brush with these settings Geçerli fırçayı bu ayarlarla güncelle Delete brush Fırçayı sil Drag the arrow to set the angle, the dots to set the roundness Açıyı ayarlamak için oku, yuvarlaklığı ayarlamak için noktaları sürükleyin Expand Genişlet @@ -2033,7 +2041,7 @@ Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr) Move Group Down Grubu Aşağı Taşı Move Group Up Grubu Yukarı Taşı - Reset the brush to the defaults Fırçayı varsayılanlara sıfırlayın + Reset the brush to the defaults Fırçayı varsayılanlara sıfırla Restore original size Orijinal boyutu geri yükle Roundness: Yuvarlaklık: Search Brushes Fırçalarda Ara @@ -2047,20 +2055,20 @@ More documents Daha fazla belge These settings aren't available in PhotoCraft yet. Bu ayarlar henüz PhotoCraft'ta mevcut değildir. Graphics Grafik - Reset GPU Backend GPU Arka Uçunu Sıfırla + Reset GPU Backend GPU Arka Ucunu Sıfırla Applies at next launch. Bir sonraki açılışta geçerlidir. - Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · {key}-arka plan için tıklayın + Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · arka plan için {key}-tıklayın {key}-click to set the source {key}-kaynağı ayarlamak için tıklayın {key} to subtract {key} çıkarmak için - Clone source {n} ({key}-click with Clone Stamp to set) Kaynağı kopyala {n} ({key}-ayarlamak için Klonlama Damgası ile tıklayın) + Clone source {n} ({key}-click with Clone Stamp to set) Klon kaynağı {n} (ayarlamak için Klonlama Damgası ile {key}-tıklayın) Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı ({key}-Klon Damgası ile tıklayın) Pixel Layer Piksel Katmanı - Type Layer Tip Katmanı + Type Layer Metin Katmanı Shape Layer Şekil Katmanı Group Layer Grup Katmanı Fill Layer Dolgu Katmanı Smart Object Layer Akıllı Nesne Katmanı - Create Warped Text Çarpık Metin Oluştur + Create Warped Text Çarpıtılmış Metin Oluştur Rasterize Smart Object Akıllı Nesneyi Rasterleştir Rasterize Fill Content Dolgu İçeriğini Rasterleştir Rasterize Layer Style Katman Stilini Rasterleştir @@ -2069,8 +2077,8 @@ Diameter: Çap: Hardness: Sertlik: No brush presets Fırça ön ayarı yok - Toggle the Brush Settings panel (F5) Fırça Ayarları panelini açın/kapatın (F5) - Create new brush preset from the current settings Geçerli ayarlardan yeni fırça hazır ayarı oluşturun + Toggle the Brush Settings panel (F5) Fırça Ayarları panelini aç/kapat (F5) + Create new brush preset from the current settings Geçerli ayarlardan yeni fırça ön ayarı oluştur Brush Settings… (F5): every option of this brush Fırça Ayarları… (F5): bu fırçanın tüm seçenekleri Rename Brush… Fırçayı Yeniden Adlandır… Delete Brush Fırçayı Sil @@ -2098,34 +2106,34 @@ Load the brush after each stroke Her vuruştan sonra fırçayı yükle Clean the brush after each stroke Her vuruştan sonra fırçayı temizle Mix Karışım - Wetness Jitter Islaklık Titreme + Wetness Jitter Islaklık Titreşimi Mix Jitter Karıştırma Titreşimi Show the layer mask as an overlay Katman maskesini kaplama olarak göster Toggle visibility Görünürlüğü aç/kapat Unlink the mask from the layer Maskenin katmanla bağlantısını kaldırın - Link the mask to the layer Maskeyi katmana bağlama - Gradient Gradyan + Link the mask to the layer Maskeyi katmana bağla + Gradient Degrade Gradient Options Degrade Seçenekleri Align with layer Katmanla hizala Reset Alignment Hizalamayı Sıfırla Centre the gradient (offset 0, 0) Degradeyi ortalayın (kaydırma 0, 0) Gradient Editor Degrade Düzenleyici Gradient picker Degrade seçici - Gradient stops Gradyan durakları + Gradient stops Degrade durakları Save As Preset Ön Ayar Olarak Kaydet The gradient the Gradient tool paints with Degrade aracının boyadığı degrade Adds the current gradient to the Gradients panel Geçerli degradeyi Degradeler paneline ekler The current gradient (pick one in Window › Gradients) Geçerli degrade (Pencere › Degradeler'den birini seçin) - Select a type layer to edit its character and paragraph settings. Karakter ve paragraf ayarlarını düzenlemek için bir yazı katmanı seçin. + Select a type layer to edit its character and paragraph settings. Karakter ve paragraf ayarlarını düzenlemek için bir metin katmanı seçin. Text color Metin rengi - Type Options Tip Seçenekleri + Type Options Metin Seçenekleri Anti-aliasing method Kenar yumuşatma yöntemi - Horizontal type Yatay tip - Vertical type Dikey tip + Horizontal type Yatay metin + Vertical type Dikey metin Fill path with foreground color Yolu ön plan rengiyle doldur Stroke path with brush Fırçayla kontur yolu Load path as a selection Yolu seçim olarak yükle - Make work path from selection Seçimden iş yolunu yap + Make work path from selection Seçimden çalışma yolu oluştur Add vector mask Vektör maskesi ekle Create new path Yeni yol oluştur Delete path Yolu sil @@ -2144,12 +2152,12 @@ Cancel opening Açılışı iptal et Cancelled opening {name} {name} açılışı iptal edildi The document “{name}” does not have an embedded {mode} profile. '{name}' belgesinde gömülü bir {mode} profili yok. - The document “{name}” has an embedded color profile that does not match the current {mode} working space. '{name}' belgesinin gömülü renk profili geçerli {mode} çalışma alanıyla eşleşmiyor. - Working: {working} Çalışma alanı: {working} + The document “{name}” has an embedded color profile that does not match the current {mode} working space. “{name}” belgesinin gömülü renk profili geçerli {mode} çalışma renk uzayıyla eşleşmiyor. + Working: {working} Çalışma renk uzayı: {working} Developing {name} {name} geliştiriliyor Cancelled {label} {label} iptal edildi Esc to cancel İptal etmek için Esc - Opened {name} {name} tarihinde açıldı + Opened {name} {name} açıldı Opening {name} {name} açılıyor Saved {name} {name} kaydedildi Saving {name} {name} kaydediliyor @@ -2166,7 +2174,7 @@ Chrome Krom Conté Crayon Conte Crayon Graphic Pen Grafik Kalemi - Halftone Pattern Noktalı Resim Deseni + Halftone Pattern Yarım Ton Deseni Note Paper Not Kağıdı Photocopy Fotokopi Plaster Alçı @@ -2176,26 +2184,28 @@ Water Paper Su Kağıdı Move Taşı Pencil Kalem - Clone Stamp Klon Damgası - Dodge Dodge - Burn Yanık + Clone Stamp Klonlama Damgası + Dodge Soldurma + Burn Yakma Quick Selection Hızlı Seçim - History Brush Tarih Fırçası + History Brush Geçmiş Fırçası + Paints from the History panel's source state Geçmiş panelinin kaynak durumundan boyar + Sets the source for the history brush Geçmiş fırçasının kaynağını ayarlar Color Replacement Renk Değiştirme New Group Yeni Grup Layer Comp Katman Kompozisyonu Alpha Alfa - Make Yap - Model Modeli + Make Üretici + Model Model F F Artist Sanatçı Spot Color Nokta Rengi - Roman Roma + Roman Roman Bold Italic Kalın İtalik - Justification Gerekçe + Justification İki Yana Yaslama Symbols Semboller Punctuation Noktalama işaretleri - Make Work Path İş Yolu Yap + Make Work Path Çalışma Yolu Oluştur Make Selection Seçim Yap Fill Path Yolu Doldur Stroke Path Kontur Yolu @@ -2204,8 +2214,8 @@ H H Ruler Cetvel New Layer Comp Yeni Katman Kompozisyonu - Duplicate Layer Comp Yinelenen Katman Kompozisyonu - Rename Layer Comp Katman Kompozisyonunu Yeniden Adlandırın + Duplicate Layer Comp Katman Kompozisyonunu Çoğalt + Rename Layer Comp Katman Kompozisyonunu Yeniden Adlandır Apply Layer Comp Katman Kompozisyonunu Uygula Update Layer Comp Katman Kompozisyonunu Güncelle Method Yöntem @@ -2215,18 +2225,18 @@ Pixel Piksel Adjustment Ayarlama Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Noktaları sürükleyin (kement) veya tıklayın (çokgen) · {add} ekle · {sub} çıkar - Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarları yeniden boyutlandırın ({ratio} oranı, {centre} merkez) · Çizim sırasında boşluk hareket eder · oklar iter · X yönünü değiştirir · {commit} gerçekleştirir · Esc iptal eder - {mode} Color · {bits} bit {mode} Renkli · {bits} bit - Scrubby Zoom Süpürge Yakınlaştırma + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarlar yeniden boyutlandırır ({ratio} oran, {centre} merkez) · çizerken Space taşır · ok tuşları hafifçe kaydırır · X yönü değiştirir · {commit} onaylar · Esc iptal eder + {mode} Color · {bits} bit {mode} Renk · {bits} bit + Scrubby Zoom Sürüklemeli Yakınlaştırma Auto Erase Otomatik Silme Color Adaptation Renk Uyarlaması - Preserve Transparency Şeffaflığı Koruyun + Preserve Transparency Saydamlığı Koru New Style… Yeni Stil… shortcut kısayol Saved Kaydedildi Arc Yay Arch Kemer - Bulge çıkıntı + Bulge Şişkinlik Shell Lower Kabuk Alt Kısmı Shell Upper Kabuk Üstü Flag Bayrak @@ -2241,35 +2251,37 @@ Rectangle Dikdörtgen New Channel Yeni Kanal Save Selection Seçimi Kaydet - Load Selection Yük Seçimi + Load Selection Seçimi Yükle @id edit.purge.undo Geri Al - Rendering Mode İşleme Modu + Rendering Mode Oluşturma Modu Automatic (recommended) Otomatik (önerilen) CPU / Compatibility CPU / Uyumluluk Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Otomatik, mümkün olduğunda GPU hızlandırmayı kullanır ve hatalarda CPU oluşturmaya geri döner. Switched to CPU rendering CPU oluşturmaya geçildi GPU acceleration could not continue. Your documents are unchanged. GPU hızlandırması devam edemedi. Belgeleriniz değişmedi. - PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft, CPU görüntü düzenleyicisini kullanıyor. Pencere hala grafik bağdaştırıcınızı kullanıyor olabilir. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft, CPU görüntü birleştiricisini kullanıyor. Pencere yine de grafik bağdaştırıcınızı kullanıyor olabilir. Retry GPU requires a restart. Save your work first. GPU'yu yeniden dene, yeniden başlatma gerektirir. Öncelikle çalışmanızı kaydedin. - Keep Using CPU CPU'yu Kullanmaya Devam Edin + Keep Using CPU CPU'yu Kullanmaya Devam Et + Retry GPU tries the graphics processor again now. GPU'yu yeniden dene, grafik işlemciyi hemen yeniden dener. + GPU acceleration resumed. GPU hızlandırması sürdürüldü. Retry GPU GPU'yu yeniden dene Advanced Gelişmiş GPU Backend GPU Arka Ucu Details Ayrıntılar Rendering mode Oluşturma modu - Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Kementi Dondur: Bir alanı dondurmak için sürükleyin, buzunu çözmek için Alt tuşuna basarak sürükleyin (L) + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Kementi Dondur: bir alanı dondurmak için sürükleyin, çözmek için Alt tuşuyla sürükleyin (L) 3 x 3 3 x 3 4 x 4 4 x 4 5 x 5 5 x 5 Camera Raw Filter ({layer}) Camera Raw Filtresi ({layer}) Camera Raw ({name}) Camera Raw ({name}) - Edit Type Türü Düzenle + Edit Type Metni Düzenle Grid: Izgara: - Highlight Clipping Warning (O) Kırpılma Uyarısını Vurgula (O) + Highlight Clipping Warning (O) Parlak Ton Kırpma Uyarısı (O) Lasso around an area, then drag the selection Bir alanı kementle çevreleyin, ardından seçimi sürükleyin Patch: Yama: Shadow Clipping Warning (U) Gölge Kırpılması Uyarısı (U) - Show Lab Color Readouts Laboratuvar Renk Okumalarını Göster + Show Lab Color Readouts Lab Renk Okumalarını Göster Show Red at 3 o'clock Kırmızıyı saat 3 yönünde göster Show Selected Region Seçilen Bölgeyi Göster Show Skin Tone Indicator Cilt Tonu Göstergesini Göster @@ -2288,16 +2300,21 @@ No patterns Desen yok Enable {name} {name}'yi etkinleştir Hold Alt while drawing for straight segments Düz parçalar çizerken Alt tuşunu basılı tutun - Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Çokgen noktalarını sürükleyin veya tıklayın · düz parçalar için kement sırasında Alt tuşunu basılı tutun · {add} ekleyin · çizim çıkarmadan önce {sub} + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Çokgen noktalarını sürükleyin veya tıklayın · düz parçalar için kement sırasında Alt tuşunu basılı tutun · {add} ekler · çizimden önce {sub} çıkarır Extend Uzat Structure: Yapı: - Content-Aware Move Tool İçeriğe Uygun Taşıma Aracı + Content-Aware Move Tool İçeriğe Duyarlı Taşıma Aracı TIFF Options TIFF Seçenekleri Targa Options Targa Seçenekleri + JPEG Options JPEG Seçenekleri + Image Options Görüntü Seçenekleri + Format Options Biçim Seçenekleri + Baseline ("Standard") Temel (“Standart”) + Baseline Optimized Optimize edilmiş temel 24 bits/pixel 24 bit/piksel 32 bits/pixel 32 bit/piksel Including layers will increase file size. Katmanların eklenmesi dosya boyutunu artıracaktır. - Discard Layers and Save a Copy Katmanları Atın ve Bir Kopyasını Kaydedin + Discard Layers and Save a Copy Katmanları At ve Kopyasını Kaydet Don't show again Bir daha gösterme Save failed: {error} Kaydetme başarısız oldu: {error} Save cancelled Kaydetme iptal edildi @@ -2315,47 +2332,47 @@ Sort Sırala Username Kullanıcı adı Display name Görünen ad - Real name Gerçek isim + Real name Gerçek ad Reverse the order Sırayı tersine çevir - Grab bag Çantayı al + Grab bag Çeşitli Table Tablo Name (A–Z) Ad (A–Z) Merged PRs Birleştirilmiş PR'ler - Commits Taahhütler + Commits Commit'ler Lines added Satırlar eklendi Lines deleted Satırlar silindi - Line delta Çizgi deltası + Line delta Satır farkı Binary assets added İkili varlıklar eklendi Binary assets removed İkili varlıklar kaldırıldı - First commit İlk taahhüt + First commit İlk commit First İlk - Last commit Son taahhüt + Last commit Son commit Last Son - Contributors: {contributors} · Commits: {commits} Katkıda Bulunanlar: {contributors} · Taahhütler: {commits} + Contributors: {contributors} · Commits: {commits} Katkıda Bulunanlar: {contributors} · Commit'ler: {commits} No contributor data was built into this copy. Bu kopyaya hiçbir katkıda bulunan verisi eklenmedi. No model credits were built into this copy. Bu kopyaya hiçbir model kredisi eklenmemiştir. Company Şirket Version Sürüm - % of all commits Tüm taahhütlerin yüzdesi - Lines +/− Çizgiler +/− - @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR'ler, {commits} taahhütler, +{added} / −{deleted} satırlar (Δ {delta}), +{binAdded} / −{binDeleted} ikili varlıklar, {first} – {last} + % of all commits Tüm commit'lerin yüzdesi + Lines +/− Satırlar +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{binAdded} / −{binDeleted} ikili varlık, {first} – {last} Create new swatch Yeni renk örneği oluştur - New Swatch… Yeni Swatch… - New Swatch Group… Yeni Swatch Grubu… + New Swatch… Yeni Renk Örneği… + New Swatch Group… Yeni Renk Örneği Grubu… Import Swatches… Renk Örneklerini İçe Aktar… - Replace Swatches… Renk Örneklerini Değiştirin… + Replace Swatches… Renk Örneklerini Değiştir… Export Swatches… Renk Örneklerini Dışa Aktar… Export Swatches for Exchange… Değişim için Renk Örneklerini Dışa Aktar… Reset Swatches Renk Örneklerini Sıfırla Add to Swatches Renk Örneklerine Ekle - Blend If: Eğer Karışım: + Blend If: Koşullu Karıştırma: Channels: Kanallar: - Knockout Nakavt - Apply At Canvas Bounds Kanvas Sınırlarında Uygula + Knockout Delme + Apply At Canvas Bounds Tuval Sınırlarında Uygula Color Picker (Text Color) Renk Seçici (Metin Rengi) Color Picker ({name}) Renk Seçici ({name}) Color Picker (Fill Color) Renk Seçici (Dolgu Rengi) - Fill color Rengi doldur + Fill color Dolgu rengi Color Picker (Stroke Color) Renk Seçici (Kontur Rengi) Stroke color Kontur rengi Pick screen color Ekran rengini seç @@ -2373,9 +2390,9 @@ Foreground Color Ön Plan Rengi Color… Renk… 50% Gray %50 Gri - {size} (was {old}) {size} ({old} idi) + {size} (was {old}) {size} (önceki: {old}) Cone Koni - Cone (Inverted) Koni (Ters Çevrilmiş) + Cone (Inverted) Koni (Ters) Domed Kubbeli Domed (Inverted) Kubbeli (Ters) Diagonal (Descending) Çapraz (Azalan) @@ -2388,13 +2405,13 @@ Cove - Deep Koy - Derin Cove - Shallow Koy - Sığ Half Round Yarım Yuvarlak - Ring - Double Yüzük - Çift Kişilik + Ring - Double Halka - Çift Rolling Slope - Descending Yuvarlanan Eğim - Azalan Rounded Steps Yuvarlak Adımlar - Sawtooth 1 Testere dişi 1 + Sawtooth 1 Testere Dişi 1 Red Eye Tool Kırmızı Göz Aracı Red Eye Kırmızı Göz - Pupil Size Öğrenci Boyutu + Pupil Size Göz Bebeği Boyutu Darken Amount Koyulaştırma Miktarı Click a red pupil to neutralize it Nötrleştirmek için kırmızı bir gözbebeğine tıklayın open source açık kaynak @@ -2408,17 +2425,17 @@ Black Matte Siyah Mat White Matte Beyaz Mat layerLabel No Color Renk Yok - Seafoam Deniz köpüğü - Indigo Çivit mavisi + Seafoam Deniz Köpüğü + Indigo Çivit Mavisi Fuchsia Fuşya Violet Menekşe Large document: redrawing on the CPU Büyük belge: CPU'da yeniden çizim This redraw took {seconds} s on the CPU. Bu yeniden çizim CPU'da {seconds} saniye sürdü. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bir katmanın karışım modu, opaklığı veya görünürlüğü gibi belgenin tamamını değiştiren düzenlemeler, belgenin tamamını yeniden çizer. - The GPU compositor wasn't used: {reason} GPU dizgicisi kullanılmadı: {reason} + The GPU compositor wasn't used: {reason} GPU birleştiricisi kullanılmadı: {reason} cameraRaw Light Açık Tonlar fontWeight Thin İnce -fontWeight ExtraLight Ekstra İnce +fontWeight ExtraLight Ekstra Hafif fontWeight Light Hafif fontWeight Regular Normal fontWeight Medium Orta @@ -2433,7 +2450,7 @@ fontWeight Italic İtalik Rotate View Görünümü Döndür Rotate View Tool Görünümü Döndürme Aracı Reset View Görünümü Sıfırla - Drag around the centre to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkezin etrafında sürükleyin · Kaydırma 15° ile sınırlandırılır + Drag around the centre to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkez etrafında sürükleyin · Shift 15° ile sınırlar @id select.all Tümünü Seç @id edit.stroke Kontur… @id edit.transform.scale Ölçekle @@ -2441,11 +2458,11 @@ cameraRaw Red Kırmızı cameraRaw Orange Turuncu cameraRaw Yellow Sarı cameraRaw Green Yeşil -cameraRaw Aqua Camgöbeği +cameraRaw Aqua Turkuaz cameraRaw Blue Mavi cameraRaw Purple Mor cameraRaw Magenta Macenta -cameraRaw Blending Karışım +cameraRaw Blending Karıştırma cameraRaw Luminance Parlaklık cameraRaw Temperature Sıcaklık cameraRaw Color Renk @@ -2461,12 +2478,12 @@ cameraRaw Color Renk Color Picker Renk Seçici Color Swatches Renk Örnekleri Linear Dodge Doğrusal Soldurma - Preview Image Resmin Önizlemesi + Preview Image Görüntü Önizlemesi Automatic Otomatik Save As Farklı Kaydet - Import… İçe aktar… + Import… İçe Aktar… Remove All Tümünü Kaldır - Unlock Kilidi aç + Unlock Kilidi Aç Enable Etkinleştir Enable animations Animasyonları etkinleştir Disable Devre dışı bırak @@ -2475,14 +2492,14 @@ cameraRaw Color Renk Clear Selection Seçimi Temizle Invert Selection Seçimi Ters Çevir Select Inverse Tersini Seç - Contract Sözleşme + Contract Daralt Sample Size Örnek Boyutu Strength Güç Gamma Gama Input Giriş Output Çıkış - Input Levels Giriş Seviyeleri - Output Levels Çıkış Seviyeleri + Input Levels Giriş Düzeyleri + Output Levels Çıkış Düzeyleri Layer Styles Katman Stilleri Blending Mode Karıştırma Modu Fill Color Dolgu Rengi @@ -2496,8 +2513,8 @@ cameraRaw Color Renk File Name Dosya Adı File Format Dosya Formatı Compression Sıkıştırma - Artboards Çalışma yüzeyleri - Snap to Grid Izgaraya Yasla + Artboards Çalışma Yüzeyleri + Snap to Grid Izgaraya Yapış Full Screen Tam Ekran Fullscreen Tam ekran Workspaces Çalışma alanları @@ -2509,9 +2526,9 @@ cameraRaw Color Renk New Layer Yeni Katman Duplicate Layer Katmanı Çoğalt Ungroup Grubu çöz - Lock Transparent Pixels Şeffaf Pikselleri Kilitle + Lock Transparent Pixels Saydam Pikselleri Kilitle Lock Image Pixels Görüntü Piksellerini Kilitle - Lock Position Kilit Konumu + Lock Position Konumu Kilitle Lock All Tümünü Kilitle Show Rulers Cetvelleri Göster Show Grid Izgarayı Göster @@ -2519,7 +2536,7 @@ cameraRaw Color Renk Page Setup… Sayfa Yapısı… Reset Preferences Tercihleri Sıfırla Documentation Dokümantasyon - Check for Updates Güncellemeleri Kontrol Et + Check for Updates Güncellemeleri Denetle Loading… Yükleniyor… Processing… İşleniyor… Saving… Kaydediliyor… @@ -2528,14 +2545,14 @@ cameraRaw Color Renk No document open Hiçbir belge açık değil No selection Seçim yok No layers Katman yok - No recent files Yeni dosya yok + No recent files Son dosya yok Unsupported file format Desteklenmeyen dosya biçimi Failed to open file Dosya açılamadı Failed to save file Dosya kaydedilemedi Operation canceled İşlem iptal edildi Changes have not been saved. Değişiklikler kaydedilmedi. - Are you sure? Emin misin? - Discard Changes Değişiklikleri İptal Et + Are you sure? Emin misiniz? + Discard Changes Değişiklikleri At Recent Files Son Dosyalar Open File Dosyayı Aç Save File Dosyayı Kaydet @@ -2551,12 +2568,12 @@ cameraRaw Color Renk Continue Devam et Skip Atla Stop Durdur - Resume Devam et + Resume Sürdür Reset All Tümünü Sıfırla Defaults Varsayılanlar Apply to All Tümüne Uygula Confirm Onayla - Finish Bitiş + Finish Bitir Back Geri Next Sonraki Previous Önceki @@ -2572,7 +2589,7 @@ cameraRaw Color Renk Change… Değiştir… Transformation Dönüşüm Scaling Ölçeklendirme - Canvas Size Kanvas Boyutu + Canvas Size Tuval Boyutu Percentage Yüzde Degrees Dereceler Middle Orta @@ -2582,7 +2599,7 @@ cameraRaw Color Renk Intensity Yoğunluk Sensitivity Hassasiyet Softness Yumuşaklık - Feather Radius Tüy Yarıçapı + Feather Radius Geçiş Yumuşatma Yarıçapı Color Profile Renk Profili Library Kütüphane Libraries Kütüphaneler @@ -2594,36 +2611,36 @@ cameraRaw Color Renk Invert Mask Maskeyi Ters Çevir Enable Mask Maskeyi Etkinleştir Disable Mask Maskeyi Devre Dışı Bırak - Apply Mask Maske Uygula - Import Settings Ayarları İçe Aktarma - Export Settings Ayarları Dışa Aktarma + Apply Mask Maskeyi Uygula + Import Settings Ayarları İçe Aktar + Export Settings Ayarları Dışa Aktar Shortcut Kısayol Shortcuts Kısayollar Keyboard Klavye Mouse Fare - Tablet tablet + Tablet Tablet Display Ekran - Panel Paneli + Panel Panel Hide Grid Izgarayı Gizle Fit to Window Pencereye Sığdır Dark Theme Koyu Tema - Light Theme Işık Teması + Light Theme Açık Tema Live Preview Canlı Önizleme - Before önce + Before Önce After Sonra Compare Karşılaştır File Size Dosya Boyutu Filename Dosya adı Folder Klasör Open Folder Klasörü Aç - Choose Folder Klasör Seçin - Browse Göz at + Choose Folder Klasör Seç + Browse Gözat Find Bul Replace Değiştir Category Kategori Date Tarih Time Zaman - Learn More Daha Fazla Bilgi Edinin + Learn More Daha Fazla Bilgi Al Report a Bug Hata Bildir Updates Güncellemeler Update Güncelleme @@ -2633,8 +2650,8 @@ cameraRaw Color Renk Quit PhotoCraft PhotoCraft'tan Çık Close Window Pencereyi Kapat New Window Yeni Pencere - Open Image Resmi Aç - Save Image Resmi Kaydet + Open Image Görüntüyü Aç + Save Image Görüntüyü Kaydet Export Image Görüntüyü Dışa Aktar Import Image Görüntüyü İçe Aktar Export As Farklı Dışa Aktar @@ -2643,16 +2660,16 @@ cameraRaw Color Renk Import Options İçe Aktarma Seçenekleri No Selection Seçim Yok No Document Belge Yok - No Image Resim Yok + No Image Görüntü Yok No Layers Katman Yok No Results Sonuç Yok No Results Found Sonuç Bulunamadı Nothing to Undo Geri Alınacak Bir Şey Yok - Nothing to Redo Tekrarlanacak bir şey yok + Nothing to Redo Yinelenecek Bir Şey Yok Changes Saved Değişiklikler Kaydedildi Unsaved Changes Kaydedilmemiş Değişiklikler Save before closing? Kapatmadan önce kaydet? - Are you sure you want to quit? Vazgeçmek istediğinden emin misin? + Are you sure you want to quit? Çıkmak istediğinizden emin misiniz? The file could not be opened. Dosya açılamadı. The file could not be saved. Dosya kaydedilemedi. Unsupported format Desteklenmeyen biçim @@ -2665,20 +2682,20 @@ cameraRaw Color Renk Camera Calibration Kamera Kalibrasyonu Snapshots Anlık görüntüler Recovery Kurtarma - Fill Light Işık Doldur + Fill Light Dolgu Işığı Convert to Grayscale Gri Tonlamaya Dönüştür Sharpness Keskinlik - Color Noise Reduction Renk Gürültü Azaltma - Luminance Smoothness Parlaklık Pürüzsüzlüğü - Focal Length35mm Odak Uzunluğu35mm + Color Noise Reduction Renk Gürültüsü Azaltma + Luminance Smoothness Lüminans Yumuşaklığı + Focal Length35mm Odak Uzunluğu (35 mm) Aspect Ratio En Boy Oranı Enter description… Açıklamayı girin… Industry Standard Endüstri Standardı Uncoated CMYK Kaplamasız CMYK - Working Spaces Çalışma Alanları + Working Spaces Çalışma Renk Uzayları Color Management Policies Renk Yönetimi Politikaları Detect Faces Yüzleri Algıla - Localize Color Clusters Renk Kümelerini Yerelleştirin + Localize Color Clusters Renk Kümelerini Yerelleştir Duplicated selection name Yinelenen seçim adı New channel only Yalnızca yeni kanal Masked Areas Maskelenmiş Alanlar @@ -2687,6 +2704,8 @@ cameraRaw Color Renk Create Snapshot Anlık Görüntü Oluştur Delete Snapshot Anlık Görüntüyü Sil New Snapshot Yeni Anlık Görüntü + New Snapshot… Yeni Anlık Görüntü… + History Options… Geçmiş Seçenekleri… Snapshot 1 Anlık Görüntü 1 Nonlinear History Doğrusal Olmayan Geçmiş Set New Snapshot as Default Yeni Anlık Görüntüyü Varsayılan Olarak Ayarla @@ -2698,12 +2717,12 @@ cameraRaw Color Renk View menu Menüyü görüntüle Keep view in sync Görünümü senkronize tutun Mouse Controls Fare Kontrolleri - Initial Direction of Stroke Başlangıç Strok Yönü - Direction of Stroke İnme Yönü + Initial Direction of Stroke Başlangıç Vuruş Yönü + Direction of Stroke Vuruş Yönü Humidity Nem Auto Clean Brush Otomatik Temizleme Fırçası Brush Tip Fırça Ucu - New Layer from Brush Brush'tan Yeni Katman + New Layer from Brush Fırçadan Yeni Katman New Brush Yeni Fırça New Tool Preset Yeni Araç Ön Ayarı Auto-Leading Otomatik Satır Aralığı @@ -2711,39 +2730,39 @@ cameraRaw Color Renk Scale Vertically Dikey Ölçeklendir Baseline Shift Taban Çizgisi Kayması Superscript Üst simge - Subscript Abonelik - Ligature Ligatür + Subscript Alt simge + Ligature Bitişik Harf Alternative Alternatif - Extra Light Ekstra Işık + Extra Light Ekstra Hafif Extra Bold Ekstra Kalın Sharpen Amount Keskinleştirme Miktarı - Sharpen Radius Yarıçapı Keskinleştir - Sharpen Detail Detayı Keskinleştir - Sharpen Masking Maskelemeyi Keskinleştir - Noise Luminance Gürültü Parlaklığı - Noise Luminance Detail Gürültü Parlaklığı Ayrıntısı + Sharpen Radius Keskinleştirme Yarıçapı + Sharpen Detail Keskinleştirme Ayrıntısı + Sharpen Masking Keskinleştirme Maskelemesi + Noise Luminance Gürültü Lüminansı + Noise Luminance Detail Gürültü Lüminansı Ayrıntısı Noise Color Gürültü Rengi Noise Color Detail Gürültü Rengi Ayrıntısı Grain Amount Tane Miktarı Grain Size Tane Boyutu - Grain Roughness Tahıl Pürüzlülüğü + Grain Roughness Tane Pürüzlülüğü Vignette Roundness Vinyet Yuvarlaklığı - Vignette Feather Vinyet Tüyü - Vignette Highlights Vinyet Öne Çıkanlar + Vignette Feather Vinyet Geçiş Yumuşatma + Vignette Highlights Vinyet Parlak Tonlar Vignette Style Vinyet Stili - Planes Uçaklar + Planes Düzlemler Corners Köşeler - Plane Uçak + Plane Düzlem Shadow Amount Gölge Miktarı Shadow Tone Gölge Tonu Shadow Radius Gölge Yarıçapı - Highlight Amount Vurgu Miktarı - Highlight Tone Vurgu Tonu - Highlight Radius Yarıçapı Vurgula + Highlight Amount Parlak Ton Miktarı + Highlight Tone Parlak Ton Tonu + Highlight Radius Parlak Ton Yarıçapı Midtone Orta ton Black Clip Siyah Klip White Clip Beyaz Klip - Fuzziness bulanıklık + Fuzziness Bulanıklık Source Layer Kaynak Katmanı Neutralize Nötrleştir Use Selection In Source Kaynaktaki Seçimi Kullan @@ -2771,18 +2790,18 @@ cameraRaw Color Renk Leaves Size Yaprak Boyutu Branches Height Dal Yüksekliği Branches Thickness Dal Kalınlığı - Default Leaves Varsayılan İzinler + Default Leaves Varsayılan Yapraklar Leaves Color Yaprak Rengi - Custom Branch Color Özel Şube Rengi + Custom Branch Color Özel Dal Rengi Branches Color Dal Rengi Flat Shading Düz Gölgeleme Enable Timeline Quick Config Zaman Çizelgesi Hızlı Yapılandırmasını Etkinleştir Set Start Timecode… Başlangıç Zaman Kodunu Ayarla… Timecode Zaman kodu - Current Time Güncel Saat + Current Time Geçerli Zaman Duration Süre Repeat Tekrarla - Play Once Bir Kez Oyna + Play Once Bir Kez Oynat Info Options… Bilgi Seçenekleri… First Color Readout İlk Renk Okuması Second Color Readout İkinci Renk Okuması @@ -2798,13 +2817,15 @@ cameraRaw Color Renk Ruler Units Cetvel Birimleri Restore last viewed comp Son görüntülenen kompozisyonu geri yükle Select All Layers Tüm Katmanları Seç - Keep proportions Oranları koruyun + Keep proportions Oranları koru Interpolation Enterpolasyon Caption Başlık Caption Credits Altyazı Kredisi Keywords Anahtar Kelimeler Copyright Info URL Telif Hakkı Bilgisi URL'si Copyright Status Telif Hakkı Durumu + Copyright Notice Telif hakkı bildirimi + Document Title Belge başlığı Copyrighted Telif hakkıyla korunuyor Public Domain Kamu Alanı Unknown Bilinmiyor @@ -2819,8 +2840,8 @@ cameraRaw Color Renk City Şehir Province / State İl / Eyalet Country Ülke - Byline Yazar yazısı - Byline Title Yazar Başlığı + Byline Yazar Satırı + Byline Title Yazar Satırı Unvanı Phone Telefon Email E-posta Last Modified Son Değiştirilme @@ -2828,13 +2849,13 @@ cameraRaw Color Renk Camera Model Kamera Modeli Camera Make Kamera Markası EXIF Version EXIF Sürümü - Exposure Program Maruziyet Programı + Exposure Program Pozlama Programı Aperture Diyafram Shutter Speed Enstantane Hızı Metering Mode Ölçüm Modu Light Source Işık Kaynağı Flash Flaş - Create Multiple Picture Packages Çoklu Resim Paketleri Oluşturun + Create Multiple Picture Packages Çoklu Resim Paketi Oluştur Choose… Seç… Select Folder… Klasör Seç… Override Action "Save" Commands Eylem "Kaydet" Komutlarını Geçersiz Kıl @@ -2845,15 +2866,15 @@ cameraRaw Color Renk Stop For Errors Hatalar İçin Durdur Log Errors To File Hataları Dosyaya Günlüğe Kaydet Settings: Ayarlar: - Working Space Çalışma Alanı + Working Space Çalışma Renk Uzayı Advanced Controls Gelişmiş Kontroller Desaturate Monitor Colors By Monitör Renklerinin Doygunluğunu Azaltma - Blend RGB Colors Using Gamma 1.0 Gama 1.0 Kullanarak RGB Renklerini Karıştırın + Blend RGB Colors Using Gamma 1.0 Gama 1.0 Kullanarak RGB Renklerini Karıştır Profile Mismatches: Ask When Opening Profil Uyuşmazlıkları: Açarken Sor Ask When Pasting Yapıştırırken Sor Missing Profiles: Ask When Opening Eksik Profiller: Açarken Sor - Embed profiles Profilleri yerleştir - Coerce to sRGB sRGB'ye baskı yapın + Embed profiles Profilleri göm + Coerce to sRGB sRGB'ye zorla Graphic and web Grafik ve web Minimalistic Minimalist Expand All Groups Tüm Grupları Genişlet @@ -2866,30 +2887,30 @@ cameraRaw Color Renk Create new count group Yeni sayım grubu oluştur Drag to move the layer Katmanı taşımak için sürükleyin Go to Layer Katmana Git - Current Mevcut + Current Geçerli Reference point location Referans noktası konumu Toggle reference point Referans noktasını değiştir - Heal Off İyileşme - Heal On İyileşme - Heal Auto Otomatik İyileştirme + Heal Off Düzeltme Kapalı + Heal On Düzeltme Açık + Heal Auto Otomatik Düzeltme Brush Size Fırça Boyutu - Heal Color Rengi İyileştir + Heal Color Rengi Düzelt Result Sonuç Fast Hızlı - Accurate Doğru - Add to Ekle + Accurate Kesin + Add to Şuna ekle: Subtract from Şundan çıkar: - Intersect with İle kesiştir - Capture Comp Comp'ı Yakala + Intersect with Şununla kesiştir: + Capture Comp Kompozisyonu Yakala No Layer Comps Katman Kompozisyonu Yok Add to selection (⇧) Seçime ekle (⇧) Click to zoom in · ⌥-click to zoom out Yakınlaştırmak için tıklayın · Uzaklaştırmak için ⌥-tıklayın - Commit Perspective Warp (↩) Perspektif Çarpıtma İşlemi (↩) - Commit Puppet Warp (↩) Kukla Çarpıtma İşlemi (↩) - Commit any current edits (⌘↩) Mevcut düzenlemeleri taahhüt et (⌘↩) + Commit Perspective Warp (↩) Perspektif Çarpıtmayı Uygula (↩) + Commit Puppet Warp (↩) Kukla Çarpıtmayı Uygula (↩) + Commit any current edits (⌘↩) Geçerli düzenlemeleri onayla (⌘↩) Commit current crop operation (↵) Mevcut kırpma işlemini gerçekleştir (↵) - Commit transform (↩) Dönüşümü kaydet (↩) - Commit warp (↩) Çözgü gerçekleştir (↩) + Commit transform (↩) Dönüşümü onayla (↩) + Commit warp (↩) Çarpıtmayı onayla (↩) Create a new layer (⇧⌘N) Yeni bir katman oluştur (⇧⌘N) Custom pattern Özel desen General Brushes Genel Fırçalar @@ -2914,7 +2935,7 @@ cameraRaw Color Renk In Black Siyah In White Beyaz Out Black Siyah - Out White Beyaz Dışı + Out White Çıkış Beyazı Tint Color Ton Rengi Stops Duraklar Center X Merkez X @@ -2922,11 +2943,11 @@ cameraRaw Color Renk Distribution Dağıtım Monochromatic Tek renkli Cell Size Hücre Boyutu - Generators Jeneratörler - Wavelength Min Dalga Boyu Min + Generators Oluşturucular + Wavelength Min Minimum Dalga Boyu Wavelength Max Maksimum Dalga Boyu - Amplitude Min Genlik Min - Amplitude Max Genlik Maksimum + Amplitude Min Minimum Genlik + Amplitude Max Maksimum Genlik Undefined Areas Tanımsız Alanlar Preserve Koru Max Radius Maksimum Yarıçap @@ -2935,10 +2956,10 @@ cameraRaw Color Renk Mask Incomplete Blocks Tamamlanmamış Blokları Maskele Stylization Stilizasyon Cleanliness Temizlik - Bristle Detail Kıl Detayı + Bristle Detail Kıl Ayrıntısı Lighting Aydınlatma Shine Parıltı - Max Offset Maksimum Ofset + Max Offset Maksimum Kaydırma Map Document Harita Belgesi Map Path Harita Yolu Map Layer Harita Katmanı @@ -2946,20 +2967,20 @@ cameraRaw Color Renk Light Type Işık Tipi Light X Işık X Light Y Işık Y - Light Z Açık Z + Light Z Işık Z Target X Hedef X Target Y Hedef Y Hotspot Sıcak nokta Elevation Yükseklik Ambient Ortam - Warmth sıcaklık - Gloss parlaklık - Metallic metalik - Ambience ortam + Warmth Sıcaklık + Gloss Parlama + Metallic Metalik + Ambience Ortam White Is High Beyaz Yüksek - Reduce Color Noise Renk Gürültüsünü Azaltın + Reduce Color Noise Renk Gürültüsünü Azalt Sharpen Details Ayrıntıları Keskinleştir - Remove Jpeg Artifact Jpeg Yapısını Kaldır + Remove Jpeg Artifact JPEG Bozulmasını Kaldır Remove Tool Aracı Kaldır Blade Curvature Bıçak Eğrisi Depth Map Derinlik Haritası @@ -2967,42 +2988,42 @@ cameraRaw Color Renk Focus Odaklanma Transition Geçiş Radius X Yarıçap X - Radius Y Y Yarıçapı + Radius Y Yarıçap Y Pins Pimler Blur Angle Bulanıklık Açısı Speed Hız Taper Konik - Start X X'i başlat - Start Y Y'yi başlat - End X X'in sonu - End Y Y Sonu - Kernel çekirdek + Start X Başlangıç X + Start Y Başlangıç Y + End X Bitiş X + End Y Bitiş Y + Kernel Çekirdek Input Mode Giriş Modu Row Order Satır Sırası Eliminate Ortadan kaldır Create By Oluşturan List Liste Pencil Width Kalem Genişliği - Stroke Pressure Strok Basıncı + Stroke Pressure Vuruş Basıncı Paper Brightness Kağıt Parlaklığı - Number Of Levels Seviye Sayısı + Number Of Levels Düzey Sayısı Edge Simplicity Kenar Sadeliği Edge Fidelity Kenar Doğruluğu - Brush Detail Fırça Detayı - Highlight Area Alanı Vurgula - Glow Size Parıltı Boyutu - Glow Brightness Parıltı Parlaklığı + Brush Detail Fırça Ayrıntısı + Highlight Area Vurgu Alanı + Glow Size Işıma Boyutu + Glow Brightness Işıma Parlaklığı Brush Type Fırça Tipi - Stroke Size Kontur Boyutu - Stroke Detail Kontur Detayı - Highlight Strength Gücü Vurgula + Stroke Size Vuruş Boyutu + Stroke Detail Vuruş Ayrıntısı + Highlight Strength Vurgu Gücü Smoothness Pürüzsüzlük Edge Thickness Kenar Kalınlığı Edge Intensity Kenar Yoğunluğu Posterization Posterleştirme - Stroke Length Strok Uzunluğu - Relief Rahatlama - Definition Tanım + Stroke Length Vuruş Uzunluğu + Relief Rölyef + Definition Belirginlik Texture Coverage Doku Kapsamı Shadow Intensity Gölge Yoğunluğu Edge Width Kenar Genişliği @@ -3010,18 +3031,18 @@ cameraRaw Color Renk Direction Balance Yön Dengesi Black Intensity Siyah Yoğunluğu White Intensity Beyaz Yoğunluğu - Dark Intensity Karanlık Yoğunluk + Dark Intensity Koyu Yoğunluğu Custom Pixel Aspect Ratio Özel Piksel En Boy Oranı 32-bit Preview Options 32 bit Önizleme Seçenekleri New Guide Yeni Kılavuz - Join the ArtCraft Discord ArtCraft Discord'una katılın + Join the ArtCraft Discord ArtCraft Discord'una Katıl Report an Issue Sorun Bildir Load Files into Stack Dosyaları Yığına Yükle Statistics İstatistikler Duplicate Layers Katmanları Çoğalt Edit Toolbar Araç Çubuğunu Düzenle Import Brushes Fırçaları İçe Aktar - Load 3D LUT 3D LUT'u yükleyin + Load 3D LUT 3D LUT'u Yükle New document Yeni belge Press keys Tuşlara basın Rename Group Grubu Yeniden Adlandır @@ -3044,7 +3065,7 @@ cameraRaw Color Renk Bleach Bypass Ağartma Baypası Blocks Bloklar Brick Tuğla - Brush Strokes Fırça Darbeleri + Brush Strokes Fırça Vuruşları Burlap Çuval bezi Candle Light Mum Işığı Circle daire @@ -3052,23 +3073,23 @@ cameraRaw Color Renk Coarse Dots Kaba Noktalar Colored Pencil Renkli Kalem Contrasty Kontrastlı - Cool Harika + Cool Soğuk Craquelure Küçük çatlak Cross Çapraz - Cross Process Çapraz Süreç + Cross Process Çapraz İşleme Crosshatch Çapraz tarama Cutout Kesim Daisies Papatyalar - Dark Rough Karanlık Sert + Dark Rough Koyu Kaba Dark Strokes Karanlık Vuruşlar Darken Only Yalnızca Koyulaştır Day For Night Gündüz Gece - Deep Blue Derin Mavi - Deep Emerald Derin Zümrüt + Deep Blue Koyu Mavi + Deep Emerald Koyu Zümrüt Deep Red Koyu Kırmızı Deep Yellow Koyu Sarı Dot Nokta - Double Line Çift Hat + Double Line Çift Çizgi Draft Taslak Dry Brush Kuru Fırça Duplication Çoğaltma @@ -3077,15 +3098,15 @@ cameraRaw Color Renk Enlarged Büyütülmüş Even Fields Çift Alanlar Faded Film Solmuş Film - Film Grain Film Tahıl - Fine iyi + Film Grain Film Tanesi + Fine İnce Fine Dots İnce Noktalar Flat Düz Fresco Fresk From Left Soldan From Right Sağdan Frosted Buzlu - Generic jenerik + Generic Genel Glass Cam Glowing Edges Parlayan Kenarlar Grainy Dots Grenli Noktalar @@ -3097,7 +3118,7 @@ cameraRaw Color Renk Ink Outlines Mürekkep Ana Hatları Ivy sarmaşık Left Diagonal Sol Çapraz - Level Based Seviyeye Dayalı + Level Based Düzeye Dayalı Light Rough Hafif Kaba Lighten Only Yalnızca Açıklaştır Long Lines Uzun Çizgiler @@ -3121,14 +3142,14 @@ cameraRaw Color Renk Palette Knife Palet Bıçağı Parallel paralel Patchwork yama işi - Pentagon Pentagon + Pentagon Beşgen Point Nokta Pointed Sivri Pond Ripples Gölet Dalgaları Pyramids Piramitler Random Rastgele - Right Diagonal Sağ Diyagonal - Ring Yüzük + Right Diagonal Sağ Çapraz + Ring Halka Rope Halat Roses Güller Sandstone Kumtaşı @@ -3139,7 +3160,7 @@ cameraRaw Color Renk Simple Basit Light Intensity Işık Yoğunluğu Spray Radius Püskürtme Yarıçapı - Stroke Direction Strok Yönü + Stroke Direction Vuruş Yönü Graininess Taneciklilik Glow Amount Işıma Miktarı Clear Amount Temizleme Miktarı @@ -3150,48 +3171,48 @@ cameraRaw Color Renk Chalk Area Tebeşir Alanı Charcoal Thickness Kömür Kalınlığı Light Dark Balance Açık Karanlık Dengesi - Foreground Level Ön Plan Seviyesi + Foreground Level Ön Plan Düzeyi Background Level Arka Plan Düzeyi Pattern Type Desen Türü Image Balance Görüntü Dengesi Darkness Karanlık - Fiber Length Elyaf Uzunluğu + Fiber Length Lif Uzunluğu Crack Spacing Çatlak Aralığı Crack Depth Çatlak Derinliği Crack Brightness Çatlak Parlaklığı - Grain Type Tahıl Türü + Grain Type Tane Türü Tile Size Döşeme Boyutu Grout Width Derz Genişliği - Lighten Grout Harcı hafifletmek + Lighten Grout Derzi Açıklaştır Square Size Kare Boyutu - Border Thickness Kenar Kalınlığı - Correct Distortion Bozulmanın Düzeltilmesi - Correct Vignette Doğru Vinyet - Correct C A Doğru CA + Border Thickness Kenarlık Kalınlığı + Correct Distortion Bozulmayı Düzelt + Correct Vignette Vinyeti Düzelt + Correct C A Kromatik Sapmayı Düzelt Auto Scale Otomatik Ölçeklendirme Red Cyan Kırmızı Camgöbeği Blue Yellow Mavi Sarı Vignette Amount Vinyet Miktarı Vignette Midpoint Vinyet Orta Noktası Constraints Kısıtlamalar - Curve Highlights Eğrinin Öne Çıkan Noktaları - Curve Lights Eğri Işıkları - Curve Darks Eğri Koyuları + Curve Highlights Eğri Parlak Tonlar + Curve Lights Eğri Açık Tonlar + Curve Darks Eğri Koyu Tonlar Curve Shadows Eğri Gölgeleri Curve Splits Eğri Bölmeleri Point Curve Nokta Eğrisi Point Curve Red Nokta Eğrisi Kırmızı Point Curve Green Nokta Eğrisi Yeşil Point Curve Blue Nokta Eğrisi Mavi - Hsl Hue Hsl Ton - Hsl Sat HSL Cumartesi - Hsl Lum Hsl Lum - Grade Shadows Sınıf Gölgeleri - Grade Midtones Derece Orta Tonları - Grade Highlights Notun Öne Çıkan Noktaları - Grade Global Küresel Sınıf - Grade Blending Sınıf Harmanlama - Grade Balance Not Dengesi + Hsl Hue HSL Ton + Hsl Sat HSL Doygunluk + Hsl Lum HSL Lüminans + Grade Shadows Derecelendirme Gölgeleri + Grade Midtones Derecelendirme Orta Tonları + Grade Highlights Derecelendirme Parlak Tonları + Grade Global Genel Derecelendirme + Grade Blending Derecelendirme Karıştırma + Grade Balance Derecelendirme Dengesi Sine sinüs Sketch Eskiz Snowflakes Kar taneleri @@ -3206,15 +3227,15 @@ cameraRaw Color Renk Star Yıldız Stars Yıldızlar Stippled noktalanmış - Stretch Streç - Teal Orange turkuaz turuncu + Stretch Esnet + Teal Orange Turkuaz Turuncu Tiny Lens Minik Mercek To Center Merkeze Triangle Üçgen Unaltered değiştirilmemiş Underpainting Alt boyama Underwater Sualtı - Uniform Üniforma + Uniform Tekdüze Upper Üst Vertical Only Yalnızca Dikey Vine With Flowers Çiçekli Asma @@ -3244,26 +3265,26 @@ cameraRaw Color Renk Picture Package… Resim Paketi… Web Photo Gallery… Web Fotoğraf Galerisi… Gray: Gri: - Spot: Nokta: + Spot: Spot: Technology Previews… Teknoloji Önizlemeleri… - 3D… 3 boyutlu… + 3D… 3B… AI Integrations Yapay Zeka Entegrasyonları Add Noise Gürültü Ekle - Add Pins Pin Ekle + Add Pins Pim Ekle Add a mask (from the selection; ⌥ inverts) Bir maske ekleyin (seçimden; ⌥ tersine çevirir) Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler. - Adobe PDF Presets… Adobe PDF Hazır Ayarları… - Advanced Blending Gelişmiş Harmanlama + Adobe PDF Presets… Adobe PDF Ön Ayarları… + Advanced Blending Gelişmiş Karıştırma After (X) (X)'ten sonra Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder. - Alternates for Current Selection Mevcut Seçimin Alternatifleri + Alternates for Current Selection Geçerli Seçimin Alternatifleri Apply Data Set Veri Kümesini Uygula - Apply Image Resmi Uygula + Apply Image Görüntüyü Uygula Arbitrary keyfi Artboard from Group Gruptan çalışma yüzeyi Artboard from Layers Katmanlardan Çalışma Yüzeyi Artboards to Files Çalışma Yüzeylerinden Dosyalara - Artboards to PDF Çalışma yüzeylerini PDF'ye dönüştürme + Artboards to PDF Çalışma Yüzeylerinden PDF'ye Ask Before Saving Saved Embedded Kaydedilen Gömülü Kaydetmeden Önce Sor Assign Profile Profil Ata At Şu tarihte: @@ -3272,57 +3293,58 @@ cameraRaw Color Renk Auto-Blend Layers Katmanları Otomatik Karıştır Auto-Hide Otomatik Gizle Auto-leading % Otomatik satır aralığı % - Avoid Widows/Orphans Dul/Yetimlerden Kaçının + Avoid Widows/Orphans Dul/Yetim Satırlardan Kaçın Background from Layer Katmandan Arka Plan Batch Toplu Blend Karışım - Border Sınır + Border Kenarlık Box Blur Kutu Bulanıklığı Calculations Hesaplamalar Camera Raw Camera Raw Camera Raw Filter Camera Raw Filtresi - Canvas rotation Kanvas döndürme - Channel1 Kanal1 - Channel2 Kanal2 - Channel3 Kanal3 - Channel4 Kanal4 + Canvas rotation Tuval döndürme + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 Clipping Path Kırpma Yolu Clone Klon Color Decontaminate Renkli Dekontaminasyon - Color Halftone Renk Noktalı Resim + Color Halftone Renk Yarım Tonu Color Lookup Tables Renk Arama Tabloları Color Settings Renk Ayarları Color Table Renk Tablosu Compose Oluştur - Condensed Yoğunlaştırılmış + Condensed Dar Conditional Mode Change Koşullu Mod Değişikliği Background (Default) Arka plan (varsayılan) - Content-Aware Fill İçeriğe Uygun Dolgu - Convert Frames > Video Layers Çerçeveleri Dönüştür > Video Katmanları + Content-Aware Fill İçeriğe Duyarlı Dolgu + Convert Frames > Video Layers Kareleri Dönüştür > Video Katmanları Convert to Linked Bağlantılıya Dönüştür Convert to Profile Profile Dönüştür - Create Date Tarih Oluştur + Create Date Oluşturma Tarihi Create Plane Düzlem Oluştur Create new layer Yeni katman oluştur Create new set Yeni grup oluştur Crystallize Kristalleş Data Veri Data Sets as Files Dosya Olarak Veri Kümeleri - DateTime Original TarihSaat Orijinal + DateTime Original Orijinal Tarih/Saat De-Interlace Taramasızlaştırma Defaults restored. Varsayılanlar geri yüklendi. Define Brush Preset Fırça Ön Ayarını Tanımla Define Custom Shape Özel Şekli Tanımla Define Pattern Deseni Tanımla - Defringe Bordürleri kaldır - Delete Pins Pinleri Sil - Diffuse Yaygın - Diffuse Glow Yaygın Parıltı + Defringe Kenar Halesini Kaldır + Delete Pins Pimleri Sil + Diffuse Yay + Diffuse Glow Dağınık Işıma Digits Rakamlar - Displace Yer değiştir - Do you want to save you made to “{name}” before quitting? Çıkmadan önce yaptığınız işi “{name}” konumuna kaydetmek istiyor musunuz? + Displace Yer Değiştir + Do you want to save you made to “{name}” before quitting? Çıkmadan önce “{name}” belgesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? Dodge/Burn Soldurma/Yakma - Duplicate Path Yinelenen Yol + Duplicate Path Yolu Çoğalt + Rename Path… Yolu Yeniden Adlandır… Dust & Scratches Toz ve Çizikler Dynamics Dinamik Edit Plane Düzlemi Düzenle @@ -3337,28 +3359,28 @@ cameraRaw Color Renk Filter Gallery Filtre Galerisi Find and Replace Text Metni Bul ve Değiştir First Line Indent İlk Satır Girintisi - Fit Image Resmi Sığdır + Fit Image Görüntüyü Sığdır Flame Alev - Flatten Frames Into Clips Çerçeveleri Klipsler halinde Düzleştirin - Flatten Frames Into Layers Çerçeveleri Katmanlara Düzleştirin + Flatten Frames Into Clips Kareleri Kliplere Düzleştir + Flatten Frames Into Layers Kareleri Katmanlara Düzleştir Focus Area Odak Alanı Follows only edges this close to the pointer ([ and ] change it) Yalnızca işaretçiye bu kadar yakın olan kenarları takip eder ([ ve ] bunu değiştirir) Frame Duration Kare Süresi - Frame Sync Çerçeve Senkronizasyonu - Gaussian Gaussian + Frame Sync Kare Senkronizasyonu + Gaussian Gauss Gaussian Blur Gauss Bulanıklığı General Blend Genel Karışım - Global Light Küresel Işık + Global Light Genel Işık Glyph Glif Glyph Scaling Glif Ölçekleme - Group from Layers Katmanlardan Gruplandır + Group from Layers Katmanlardan Grup Group · {n} layer Grup · {n} katman HDR Toning HDR Tonlama - HSB Color HSB Rengi - HSL/Grayscale HSL/Gri tonlamalı + HSB Color HSB Renk + HSL/Grayscale HSL/Gri Tonlamalı Hanging Punctuation Asılı Noktalama İşaretleri - Heal İyileşmek - Healing Şifa + Heal Düzelt + Healing Düzeltme Hide the layer's effects (⌥-click: all layers) Katmanın efektlerini gizleyin (⌥ tuşuna basarak tıklayın: tüm katmanlar) High Pass Yüksek Geçiş Higher values follow only edges that contrast sharply with their surroundings Daha yüksek değerler yalnızca çevreleriyle keskin bir kontrast oluşturan kenarları takip eder @@ -3373,14 +3395,14 @@ cameraRaw Color Renk Intersect with selection (⇧⌥) Seçimle kesiştir (⇧⌥) Iris Blur İris Bulanıklığı Jag Jag - Justify Gerekçelendir - Keeps the current texture when switching presets. Ön ayarları değiştirirken mevcut dokuyu korur. + Justify İki Yana Yasla + Keeps the current texture when switching presets. Ön ayarları değiştirirken geçerli dokuyu korur. Label Etiket - Layer Comps to Files Dosyalara Katman Kompozisyonları + Layer Comps to Files Katman Kompozisyonlarından Dosyalara Layer Content Options Katman İçeriği Seçenekleri Layer From Background Arka Plandan Katman Layer from Background Arka Plandan Katman - Layers to Files Dosyalara Katmanlar + Layers to Files Katmanlardan Dosyalara Legacy Eski Lens Objektif Lens Blur Mercek Bulanıklığı @@ -3394,16 +3416,16 @@ cameraRaw Color Renk Liquify Sıvılaştır Lock Layers Katmanları Kilitle Loop Döngü - Lum Lum + Lum Lüminans Match Color Rengi Eşleştir Measure Ölçü Merge to HDR Pro HDR Pro'ya Birleştir Mezzotint Mezotint - Modify Date Tarihi Değiştir + Modify Date Değiştirilme Tarihi Mosaic Mozaik Motion Blur Hareket Bulanıklığı Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosya › Aç'ı kullanın veya PhotoCraft'ı XWayland altında `WAYLAND_DISPLAY= photocraft` ile çalıştırın. - Neon Glow Neon Parıltı + Neon Glow Neon Işıma New Guide Layout Yeni Kılavuz Düzeni New Path Yeni Yol New Video Group Yeni Video Grubu @@ -3418,47 +3440,47 @@ cameraRaw Color Renk Package Paket Paint Daubs Boya Lekeleri Path Blur Yol Bulanıklığı - Path to Illustrator… İllüstratöre Giden Yol… + Path to Illustrator… Yoldan Illustrator'a… Path: Yol: - Paths to Illustrator Illustrator'a Giden Yollar + Paths to Illustrator Yollardan Illustrator'a Photomerge Fotoğraf Birleştir Picture Frame Resim Çerçevesi - Pinch Tutam - Place Embedded Gömülü Yer - Place Linked Bağlantılı Yer - Place Scale Marker Ölçek İşaretçisini Yerleştirin + Pinch Sıkıştır + Place Embedded Gömülü Olarak Yerleştir + Place Linked Bağlantılı Olarak Yerleştir + Place Scale Marker Ölçek İşaretçisini Yerleştir Plastic Wrap Plastik Sarma Pointillize Noktalaştır Polar Coordinates Kutupsal Koordinatlar - Polar To Rectangular Polar'dan Dikdörtgen'e + Polar To Rectangular Kutupsaldan Dikdörtgene Poster Edges Poster Kenarları Preset Manager Ön Ayar Yöneticisi Profile Profil Quick Sync Hızlı Senkronizasyon - Radial Blur Radyal Bulanıklık + Radial Blur Radyal Bulanıklığı Raw Data Ham Veri Raw Defaults Ham Varsayılanlar - Rectangular To Polar Dikdörtgenden Polar'a - Reduce Noise Gürültüyü Azaltın - Relink to File Dosyaya Yeniden Bağlan + Rectangular To Polar Dikdörtgenden Kutupsala + Reduce Noise Gürültüyü Azalt + Relink to File Dosyaya Yeniden Bağla Render Video Videoyu Oluştur Replace Color Rengi Değiştir Replace Contents İçeriği Değiştir Replace Footage Görüntüyü Değiştir - Resolve Missing Fonts Eksik Yazı Tiplerini Çözün + Resolve Missing Fonts Eksik Yazı Tiplerini Çöz Ripple Dalgalanma - Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Roman Hanging Punctuation Roman Asılı Noktalama İşaretleri Rotate Auto Otomatik Döndür Rotate Fixed Sabit Döndür Rough Pastels Kaba Pastel - Sat Cumartesi + Sat Doygunluk Save a Copy Bir Kopyasını Kaydet Save for Web (Legacy) Web için Kaydet (Eski) Saved Multichannel Çok Kanallı Kaydedildi - Scale Effects Ölçek Efektleri + Scale Effects Efektleri Ölçekle Script Events Manager Komut Dosyası Olay Yöneticisi Search menus Arama menüleri - Seed tohum + Seed Tohum Select Plane Düzlem Seç Select and Mask Seç ve Maskele Semi Bold Yarı Kalın @@ -3467,7 +3489,7 @@ cameraRaw Color Renk Shape Blur Şekil Bulanıklığı Shear Kesme Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar) - Single Word Justification Tek Kelime Gerekçesi + Single Word Justification Tek Sözcüğü İki Yana Yaslama Smart Blur Akıllı Bulanıklaştırma Smart Sharpen Akıllı Keskinleştirme Smudge Stick Leke Çubuğu @@ -3477,7 +3499,7 @@ cameraRaw Color Renk Spin Blur Döndürme Bulanıklığı Split Toning Bölünmüş Tonlama Split at Playhead Oynatma Kafasında Böl - Spot nokta + Spot Spot Spot Channel Spot Kanal Sprayed Strokes Püskürtülmüş Vuruşlar Strokes Vuruşlar @@ -3498,42 +3520,42 @@ cameraRaw Color Renk Tree Ağaç Trim Kırpma Turbulent Çalkantılı - Twirl Burgu + Twirl Girdap Ultra Bold Ultra Kalın Ultra Light Ultra Hafif Unsharp Mask Keskinliği Azaltma Maskesi - Use File › Open to open an image. Bir resmi açmak için Dosya › Aç'ı kullanın. + Use File › Open to open an image. Bir görüntüyü açmak için Dosya › Aç'ı kullanın. Vanishing Point Ufuk Noktası - Variable Data Sets Değişken Veri Setleri + Variable Data Sets Değişken Veri Kümeleri Video Frames to Layers Video Karelerinden Katmanlara WIA Support WIA Desteği - Warp Text Metni Çözgü - Watercolour look: lighter interior, darker rims. Suluboya görünümü: daha açık iç kısım, daha koyu jantlar. + Warp Text Metni Çarpıt + Watercolour look: lighter interior, darker rims. Suluboya görünümü: daha açık iç kısım, daha koyu kenarlar. Wind Rüzgâr Word Spacing Kelime Aralığı - Working Gray Plate Gri Çalışma Plakası - ZigZag ZigZag + Working Gray Plate Çalışma Gri Plakası + ZigZag Zikzak ↑↓ navigate ↵ run esc close ↑↓ gezin ↵ çalıştır esc kapat cameraRaw Amount Miktar cameraRaw Blacks Siyahlar -cameraRaw Color Detail Renk Detayı +cameraRaw Color Detail Renk Ayrıntısı cameraRaw Color Grading Renk Derecelendirme -cameraRaw Darks Koyular +cameraRaw Darks Koyu Tonlar cameraRaw Global Genel cameraRaw Hue Ton -cameraRaw Lights Açıklar -cameraRaw Luminance Detail Parlaklık Detayı +cameraRaw Lights Açık Tonlar +cameraRaw Luminance Detail Lüminans Ayrıntısı cameraRaw Masking Maskeleme cameraRaw Roughness Pürüzlülük cameraRaw Tint Renk Tonu -cameraRaw Vibrance Titreşim +cameraRaw Vibrance Canlılık cameraRaw Whites Beyazlar Wand Değnek Note tool Not aracı Magnetic Lasso Manyetik Kement - Art History Brush Sanat Tarihi Fırçası - Spot Healing Nokta Şifası - Content-Aware Move İçeriğe Uygun Taşıma + Art History Brush Sanat Geçmişi Fırçası + Spot Healing Nokta Düzeltme + Content-Aware Move İçeriğe Duyarlı Taşıma Vertical Type Tool Dikey Metin Aracı Click the first point or press {key} to close · {del} removes a point · Esc cancels İlk noktayı tıklayın veya kapatmak için {key} tuşuna basın · {del} bir noktayı kaldırır · Esc iptal eder Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Tıklayın, ardından bir kenar boyunca hareket edin · bir noktayı sabitlemek için tıklayın · düz bir parça için {alt}-tıklayın · kapatmak için çift tıklayın @@ -3542,7 +3564,7 @@ cameraRaw Whites Beyazlar Quit {app} {app}'dan çık Services Hizmetler Hide Others Diğerlerini Gizle - 3D 3D + 3D 3B Pen Kalem Hand El Sampler Örnekleyici @@ -3550,18 +3572,18 @@ cameraRaw Whites Beyazlar Perspective Crop Perspektif Kırpma RGB RGB ISO ISO - ColorSync Renk Senkronizasyonu - ProPhoto ProFotoğraf + ColorSync ColorSync + ProPhoto ProPhoto sRGB sRGB ACES ACES {zoom}% %{zoom} - Mac LCD Mac LCD'si + Mac LCD Mac LCD Mac Sharp Mac Keskin - Mac Crisp Mac Çıtır + Mac Crisp Mac Net Mac Strong Mac Güçlü - Mac Smooth Mac Pürüzsüz - Adobe Single-line Composer Adobe Tek Satır Oluşturucu - Adobe Every-line Composer Adobe Her Satır Oluşturucusu + Mac Smooth Mac Yumuşak + Adobe Single-line Composer Adobe Tek Satırlı Dizgi Motoru + Adobe Every-line Composer Adobe Tüm Satırlı Dizgi Motoru X X Y Y 30 fps 30 fps @@ -3569,7 +3591,7 @@ cameraRaw Whites Beyazlar 24 fps 24 fps 15 fps 15 fps 10 fps 10 fps - URLs URLs + URLs URL'ler EXIF EXIF GPS GPS Mac OS Mac OS @@ -3585,7 +3607,7 @@ cameraRaw Whites Beyazlar NTSC (1953) NTSC (1953) PAL/SECAM PAL/SECAM SMPTE C SMPTE-C - HDTV (Rec. 709) HDTV (Kayıt 709) + HDTV (Rec. 709) HDTV (Rec. 709) CIE RGB CIE RGB Photo (2 × 3 in) Fotoğraf (2 × 3 inç) Photo (4 × 6 in) Fotoğraf (4 × 6 inç) @@ -3593,11 +3615,11 @@ cameraRaw Whites Beyazlar Photo (8 × 10 in) Fotoğraf (8 × 10 inç) Lut Lut Cooling L B B Soğutma L B B - Cooling80 Soğutma80 - Cooling82 Soğutma82 + Cooling80 Soğutma 80 + Cooling82 Soğutma 82 Hsb HSB Hsl HSL - Movie Prime Film Başbakanı + Movie Prime Film Prime Prime105 Prime105 Prime35 Prime35 Rgb RGB @@ -3611,12 +3633,12 @@ cameraRaw Whites Beyazlar Out of gamut for printing: click for the nearest printable colour Yazdırma için gamın dışında: yazdırılabilir en yakın renk için tıklayın Not a web colour: click for the nearest web colour Web rengi değil: en yakın web rengi için tıklayın Adjustment Properties Ayarlama Özellikleri - Brush Stroke Fırça Darbesi + Brush Stroke Fırça Vuruşu Default Foreground and Background Colors Varsayılan Ön Plan ve Arka Plan Renkleri Move Layer Katmanı Taşı Rectangular Selection Dikdörtgen Seçimi Reorder Layer Katmanı Yeniden Sırala - Select Layer Katman Seç + Select Layer Katmanı Seç Set Colors Renkleri Ayarla Switch Foreground and Background Colors Ön Plan ve Arka Plan Renklerini Değiştir Target Hedef @@ -3634,20 +3656,20 @@ cameraRaw Whites Beyazlar Sample Size: Örnek Boyutu: Show Sampling Ring Örnekleme Halkasını Göster Dual Axis Çift Eksen - Diagonal diyagonal + Diagonal Çapraz Transform Symmetry Simetriyi Dönüştür Rasterize svg groups deeper than Svg gruplarını şundan daha derin bir şekilde rasterleştirin: 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 bu eşiği kapatır; 100 düzeyden daha derine yerleştirilmiş SVG grupları her zaman rasterleştirilir. Rule of Thirds Üçte Bir Kuralı Golden Ratio Altın Oran Golden Spiral Altın Spiral - Auto Show Overlay Otomatik Göster Yer Paylaşımı + Auto Show Overlay Kaplamayı Otomatik Göster Always Show Overlay Kaplamayı Her Zaman Göster Never Show Overlay Kaplamayı Hiçbir Zaman Gösterme - Cycle Overlay Döngü Yerleşimi - Cycle Orientation Döngü Yönü - Set the overlay options for the Crop tool Kırpma aracının kaplama seçeneklerini ayarlama - Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt tuşuna basıp masaüstüne giderek pencereleri Alt tuşuyla sürükleyerek hareket ettirin. Pencere taşıma tuşunu Süper olarak ayarlayın veya Pencere › Değiştirici Tuşlar'da Alt'ı mandallayın. + Cycle Overlay Kaplamalar Arasında Geç + Cycle Orientation Yönler Arasında Geç + Set the overlay options for the Crop tool Kırpma aracının kaplama seçeneklerini ayarla + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-tıklama masaüstüne gitti; masaüstü pencereleri Alt-sürükleyerek taşıyor. Pencere taşıma tuşunu Super olarak ayarlayın veya Pencere › Değiştirici Tuşlar'da Alt'ı sabitleyin. Color Picker (Layer Style Color) Renk Seçici (Katman Stili Rengi) Auto Hide Notifications Bildirimleri Otomatik Gizle Notification Duration (seconds) Bildirim Süresi (saniye) @@ -3657,18 +3679,18 @@ cameraRaw Whites Beyazlar Presentation Sunum Next Appearance Mode Sonraki Görünüm Modu Sync with system Sistemle senkronizasyon - Active Aktif + Active Etkin Color Picker (Solid Color) Renk Seçici (Düz Renk) - Straighten the image by drawing a line on it Üzerine bir çizgi çizerek görüntüyü düzeltin + Straighten the image by drawing a line on it Üzerine bir çizgi çizerek görüntüyü doğrultun Set additional Crop options Ek Kırpma seçeneklerini ayarlayın Show Cropped Area Kırpılmış Alanı Göster Enable Crop Shield Kırpma Kalkanını Etkinleştir - Match Canvas Maç Kanvası + Match Canvas Tuvalle Eşleştir Auto Adjust Opacity Opaklığı Otomatik Ayarla Crop Shield color Kırpma Kalkanı rengi Color Picker (Crop Shield Color) Renk Seçici (Kırpma Kalkanı Rengi) From Center Merkezden - Link radii Bağlantı yarıçapları + Link radii Yarıçapları bağla Star Ratio: Yıldız Oranı: Create Rectangle Dikdörtgen Oluştur Create Ellipse Elips Oluştur @@ -3678,37 +3700,37 @@ cameraRaw Whites Beyazlar Click to add a point, right-click to remove Nokta eklemek için tıklayın, kaldırmak için sağ tıklayın Some brush presets could not be loaded Bazı fırça ön ayarları yüklenemedi Retry saving presets Ön ayarları kaydetmeyi yeniden deneyin - Brush preset changes are not saved: {error} Fırça hazır ayarı değişiklikleri kaydedilmiyor: {error} + Brush preset changes are not saved: {error} Fırça ön ayarı değişiklikleri kaydedilmedi: {error} Keep this tab open to retain your brushes. Fırçalarınızı korumak için bu sekmeyi açık tutun. Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Tarayıcı depolama alanı kullanılamıyor; fırçalar yalnızca oturum içindir. Bunları korumak için bu sekmeyi açık tutun. Saving brush presets… Keep this tab open until saving finishes. Fırça ön ayarları kaydediliyor… Kaydetme bitene kadar bu sekmeyi açık tutun. Reflect Yansıt - Glow Parıltı + Glow Işıma Negation Olumsuzluk XOR XOR - Color Burn (Paint.NET) Renk Yanması (Paint.NET) + Color Burn (Paint.NET) Renk Yakma (Paint.NET) Color Dodge (Paint.NET) Renk Soldurma (Paint.NET) - Pro Medium Profesyonel Orta - Studio Light Stüdyo Işığı + Pro Medium Pro Orta + Studio Light Stüdyo Açık Stroke Options Kontur Seçenekleri - Solid Katı + Solid Sürekli Dashed Kesikli Dotted Noktalı - Caps Kapaklar - Butt / Flat Popo / Düz + Caps Uçlar + Butt / Flat Düz Uç / Düz Miter Gönye Round Yuvarlak Bevel Eğim Miter limit Gönye sınırı Dashed line Kesikli çizgi - Dash Çizgi + Dash Tire Gap Boşluk - Dash / gap lengths × stroke width Çizgi / boşluk uzunlukları × kontur genişliği + Dash / gap lengths × stroke width Tire / boşluk uzunlukları × kontur genişliği Add pair Çift ekle Remove pair Çifti kaldır - Dash offset Çizgi ofseti + Dash offset Tire kaydırması Open paths use centered strokes Açık yollar ortalanmış konturlar kullanır - Use finite lengths and a positive pattern total Sonlu uzunluklar ve pozitif bir model toplamı kullanın + Use finite lengths and a positive pattern total Sonlu uzunluklar ve pozitif bir desen toplamı kullanın No subject found — try Object Selection or the Lasso Konu bulunamadı — Nesne Seçimi'ni veya Kement'i deneyin Quick Selection found nothing there Hızlı Seçim orada hiçbir şey bulamadı Object Selection found nothing there — try a tighter rectangle Nesne Seçimi burada hiçbir şey bulamadı; daha dar bir dikdörtgen deneyin @@ -3722,15 +3744,15 @@ cameraData Date Taken Çekim Tarihi cameraData Exposure Pozlama cameraData F-Stop Diyafram cameraData ISO Speed ISO Hızı -cameraData Focal Length Odak Uzaklığı -cameraData Focal Length (35 mm) Odak Uzaklığı (35 mm) +cameraData Focal Length Odak Uzunluğu +cameraData Focal Length (35 mm) Odak Uzunluğu (35 mm) cameraData Lens Lens Activate Panel Paneli Etkinleştir Change Panel Group Panel Grubunu Değiştir Change Panel Layout Panel Düzenini Değiştir Close Panel Paneli Kapat - Dock Panel Bağlantı İstasyonu Paneli - Float Panel Şamandıra Paneli + Dock Panel Paneli Sabitle + Float Panel Paneli Yüzdür Move Panel Paneli Taşı Group with Şununla grupla: Panel menu Panel menüsü @@ -3741,16 +3763,16 @@ cameraData Lens Lens Next Document Sonraki Belge Previous Document Önceki Belge W x H x Resolution G x Y x Çözünürlük - Front Image Ön Resim + Front Image Ön Görüntü Perspective Crop Tool Perspektif Kırpma Aracı - Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Bir kutuyu sürükleyin veya dört köşeyi tıklayın · köşeleri çarpık bir şeklin kenarlarına sürükleyin · {commit} taahhütleri · Esc iptalleri + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Bir kutuyu sürükleyin veya dört köşeye tıklayın · köşeleri çarpık bir şeklin kenarlarına sürükleyin · {commit} onaylar · Esc iptal eder Artboard Tool Çalışma Yüzeyi Aracı - Make Portrait Portre Yap - Make Landscape Manzara Yap + Make Portrait Dikey Yap + Make Landscape Yatay Yap Add New Artboard Yeni Çalışma Yüzeyi Ekle Freeform Pen Tool Serbest Kalem Aracı Curvature Pen Tool Eğrilik Kalemi Aracı - Curve Fit: Eğri Fitili: + Curve Fit: Eğri Uyumu: Drag: draw freehand · Release on the start point: close Sürükleyin: serbest çizim yapın · Başlangıç noktasında bırakın: kapat Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Tıklama: düzgün nokta · Çift tıklama: köşe · Bir noktayı sürükleyin: taşıma · İlk noktayı tıklayın: kapat · {key} bitiş New Crop Preset… Yeni Kırpma Ön Ayarı… @@ -3770,12 +3792,12 @@ cameraData Lens Lens On Layers Katmanlarda Show Edge Kenarı Göster Show Original Orijinali Göster - F cycles the views; hold X to see the image F görünümleri döndürür; resmi görmek için X'i basılı tutun - Refine Edge Brush (R) Kenar Fırçasını İncelt (R) + F cycles the views; hold X to see the image F görünümleri döndürür; görüntüyü görmek için X'i basılı tutun + Refine Edge Brush (R) Kenarı Hassaslaştırma Fırçası (R) Expand Detection Area Algılama Alanını Genişlet Restore Original Edge Orijinal Kenarı Geri Yükle - Paint over hair or fur on the image; Alt paints the other mode Görüntünün üzerine saç veya kürkün üzerini boyayın; Alt diğer modu boyar - Clear Brush Strokes Fırça Darbelerini Temizle + Paint over hair or fur on the image; Alt paints the other mode Görüntüde saç veya kürk üzerine boyayın; Alt diğer modu boyar + Clear Brush Strokes Fırça Vuruşlarını Temizle Show rulers Cetvelleri göster Frame Tool Çerçeve Aracı Create a new rectangular frame Yeni bir dikdörtgen çerçeve oluşturun @@ -3791,7 +3813,7 @@ cameraData Lens Lens Transparency Threshold Saydamlık Eşiği Opacity Threshold Opaklık Eşiği (No neutral color exists for {mode} mode.) ({mode} modu için nötr renk bulunmuyor.) - Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Sürükle: serbest çiz · {alt}-tıkla: düz segment · Başlangıç noktasında bırak: kapat + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Sürükle: serbest çiz · {alt}-tıkla: düz parça · Başlangıç noktasında bırak: kapat Fill with {mode}-neutral color (50% gray) {mode} nötr rengiyle doldur (%50 gri) Fill with {mode}-neutral color (black) {mode} nötr rengiyle doldur (siyah) Fill with {mode}-neutral color (white) {mode} nötr rengiyle doldur (beyaz) @@ -3809,7 +3831,7 @@ cameraData Lens Lens Light Red Açık Kırmızı Medium Blue Orta Mavi Merge Group Grubu Birleştir - Protect skin tones Ten tonlarını koru + Protect skin tones Cilt tonlarını koru Protect: Koru: Remove after each stroke Her vuruştan sonra kaldır Saving {percent} Kaydediliyor {percent} @@ -3834,6 +3856,12 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + Load Contours… Konturları yükle… + Replace Contours… Konturları değiştir… + Save Contours… Konturları kaydet… + Delete Contour Konturu sil + Loaded contours from {file} Konturlar {file} dosyasından yüklendi + Saved contours to {file} Konturlar {file} dosyasına kaydedildi # Edit › Content-Aware Fill workspace Excluded Area Hariç Tutulan Alan Fill Settings Dolgu Ayarları @@ -3859,6 +3887,9 @@ cameraData Lens Lens # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Eylemleri Yükle… Save Actions… Eylemleri Kaydet… + New Action… Yeni Eylem… + Start Recording Kaydı Başlat + Stop Recording Kaydı Durdur {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} dosyasından {n} eylem yüklendi @@ -3879,10 +3910,11 @@ cameraData Lens Lens @plural {n} action|{n} actions {n} eylem Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Etkin katmanda burada piksel yok — Tüm Katmanları Örnekle seçeneğini açın veya piksel içeren bir katman seçin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın - Gradient Preset Options Gradyan Ön Ayarı Seçenekleri + Gradient Preset Options Degrade Ön Ayarı Seçenekleri Import .GRD .GRD İçe Aktar - Choose a gradient file (.grd). Bir gradyan dosyası (.grd) seçin. + Choose a gradient file (.grd). Bir degrade dosyası (.grd) seçin. Save Settings… Ayarları Kaydet… Load Settings… Ayarları Yükle… Save these settings as a Camera Raw .xmp file Bu ayarları Camera Raw .xmp dosyası olarak kaydet @@ -3899,12 +3931,23 @@ cameraData Lens Lens set in Print Settings… Yazdırma Ayarlarında belirlenir… ICO is limited to 256 × 256 pixels ICO 256 × 256 piksel ile sınırlıdır +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Maske Seçenekleri… + Layer Mask Display Options… Katman Maskesi Görüntüleme Seçenekleri… + # Channel Options and New Spot Channel (#3761) Channel Color Kanal Rengi Color Indicates: Rengin Gösterdiği: Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: Redrawing Yeniden çiziliyor +# Bevel & Emboss shading controls. + Highlight Mode Vurgu Modu + Highlight Color Vurgu Rengi + Highlight Opacity Vurgu Opaklığı + Shadow Mode Gölge Modu + Shadow Color Gölge Rengi + Shadow Opacity Gölge Opaklığı # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Gömülü profili kullanın ({profile}) Convert to the working space ({profile}) Çalışma alanına dönüştürün ({profile}) @@ -3921,10 +3964,14 @@ cameraData Lens Lens Chisel Hard Sert Keski Chisel Soft Yumuşak Keski Open color-less EXR as ACEScg Renk bilgisi olmayan EXR'yi ACEScg olarak aç + Do not append "copy" to filename when saving a copy Kopya kaydederken dosya adına "copy" ekleme # View › Guides › right-click a guide Edit Selected Guides Seçili Kılavuzları Düzenle Clear Selected Guides Seçili Kılavuzları Temizle + Bicubic Automatic Bikübik Otomatik + Bicubic Smoother Bikübik Pürüzsüzleştirici + Bicubic Sharper Bikübik Daha Keskin Pasting from other apps is unavailable Diğer uygulamalardan yapıştırma kullanılamıyor PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft bu oturumda sistem panosuna erişemiyor. Wayland'de Flatpak korumalı alanı, diğer uygulamalardaki görüntülerin okunduğu protokolü gizler. To paste from other apps, start PhotoCraft under XWayland: `{command}` Diğer uygulamalardan yapıştırmak için PhotoCraft'ı XWayland altında başlatın: `{command}` @@ -3944,6 +3991,43 @@ cameraData Lens Lens set name küme adı {set} (modified) {set} (değiştirildi) Show file name in title Başlıkta dosya adını göster + +# Image › Mode › Bitmap (#5559) + 50% Threshold %50 Eşik + Pattern Dither Desen Titreklik + Diffusion Dither Difüzyon Titreklik + Halftone Screen Yarım Ton Tarama + Custom Pattern Özel Desen + Frequency: Frekans: + Lines/Inch Çizgi/İnç + Lines/Centimeter Çizgi/Santimetre + Ellipse Elips + +# Layers panel options and Smart Filter editors + Add “copy” to Copied Layers and Groups Kopyalanan Katman ve Gruplara “kopya” Ekle + Animation Options Animasyon Seçenekleri + Entire Document Tüm Belge + Expand New Effects Yeni Efektleri Genişlet + Filter Options Filtre Seçenekleri + Layer Bounds Katman Sınırları + Layers Panel Options Katmanlar Paneli Seçenekleri + New Artboard… Yeni Çalışma Yüzeyi… + New Group… Yeni Grup… + Prevent Auto-Nesting Otomatik Yerleştirmeyi Önle + Preview on canvas when reordering layers Katmanları yeniden sıralarken kanvasta önizle + Show Layers Katmanları Göster + Show layer mask badges Katman maskesi rozetlerini göster + Thumbnail Contents Küçük Resim İçeriği + Thumbnail Size Küçük Resim Boyutu + Use Default Masks on Fill Layers Dolgu Katmanlarında Varsayılan Maskeleri Kullan + + Blending Options ({filter}) Karışım Seçenekleri ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Katmanın efektlerini ve Akıllı Filtrelerini gizle ({key}-tık: tüm katmanlar) + Opacity must be between 0% and 100%. Opaklık %0 ile %100 arasında olmalıdır. + Dissolve is approximated: it blends without Photoshop's dither. Eritme yaklaşık olarak uygulanır: Photoshop'un dither'ı olmadan karıştırır. + Show the layer's effects and Smart Filters ({key}-click: all layers) Katmanın efektlerini ve Akıllı Filtrelerini göster ({key}-tık: tüm katmanlar) + The document or Smart Filter changed. Reopen its dialog. Belge veya Akıllı Filtre değişti. İletişim kutusunu yeniden açın. + Wait for the current job to finish. Geçerli işin bitmesini bekleyin. Accept and Go to Conflict Kabul Et ve Çakışmaya Git Changes undone. Değişiklikler geri alındı. Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın. @@ -3961,15 +4045,33 @@ cameraData Lens Lens GPU Memory Full GPU Belleği Dolu The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Katmanların {needed} GPU belleğine ihtiyacı var ve en fazla {budget} kullanabilirler; bu yüzden tüm belgeyi yeniden çizen düzenlemeler bunu CPU'da yapar. GPU Belleği'ni ayarlamak için tıklayın. GPU memory budget: {budget} GPU bellek bütçesi: {budget} + Graphics memory: {size} Grafik belleği: {size} This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) + {path}: the path is not valid Unicode; save under another name. {path}: yol geçerli bir Unicode değil; başka bir adla kaydedin. {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. Decrease Brush Size Fırça boyutunu küçült Increase Brush Size Fırça boyutunu büyüt Decrease Brush Hardness Fırça sertliğini azalt Increase Brush Hardness Fırça sertliğini artır + Demosaic Demozaikleme + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Kameranın renk filtresi mozaiğinin nasıl doldurulduğu. Dosya açılırken uygulanır; önizleme dosyanın açıldığı yöntemi gösterir. + AHD (adaptive homogeneity) AHD (uyarlamalı homojenlik) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: en temiz kenarlar ve en az renk saçağı, en yavaş. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Gradyan düzeltmeli ara değerleme: keskin ve hızlı, ince ayrıntılarda biraz renk saçağı. + The fastest and the softest, with colour fringing on fine detail. En hızlı ve en yumuşak, ince ayrıntılarda renk saçağı. + “{name}” already exists. Replace it? '{name}' zaten var. Değiştirilsin mi? + The name in the save dialog was “{name}”. Kaydetme penceresindeki ad '{name}' idi. + Move Up Yukarı taşı + Move Down Aşağı taşı + Previous Brush Önceki fırça + Next Brush Sonraki fırça + First Brush İlk fırça + Last Brush Son fırça + Preview size Preview size Memory Usage Bellek kullanımı Per-document RAM limit (MB) Belge başına RAM sınırı (MB) Installed RAM: {ram} MB; this limit: {percent}% Kurulu RAM: {ram} MB; bu sınır: {percent}% @@ -3984,5 +4086,28 @@ cameraData Lens Lens DDS Options DDS Seçenekleri Generate Mipmaps Mipmap oluştur +# Color Table (.act) dialog + There is no color table to save. Kaydedilecek renk tablosu yok. +@plural Saved {n} color to {file}|Saved {n} colors to {file} {n} renk {file} konumuna kaydedildi + # Graphics Adapter preference (#1885) GPU adapter GPU bağdaştırıcısı +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin + Pro Medium Gray Pro Orta Gri + Solarized Solarize + Breeze Esinti + Pro Light Gray Pro Açık Gri + Pro Light Profesyonel Işık + Resize Windows to Fit Pencereleri sığacak şekilde boyutlandır + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Otomatik paragraf yönü + Left-to-right paragraph direction Soldan sağa paragraf yönü + Right-to-left paragraph direction Sağdan sola paragraf yönü + Not rendered: this adjustment ({key}) is kept as imported. Görüntülenmiyor: bu ayarlama ({key}) içe aktarıldığı gibi korunuyor. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e28ea0008c..a96f028048 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8,6 +8,10 @@ # No proprietary translation resources were used. # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Параметри маски… + Layer Mask Display Options… Параметри відображення маски шару… + # Channel Options and New Spot Channel (#3761) Channel Color Колір каналу Color Indicates: Кольором позначено: @@ -234,6 +238,7 @@ Inner Glow… Внутрішнє світіння… Bevel & Emboss… Фаска та рельєф… Use Global Light Використовувати глобальне освітлення + Overprint Накладання Satin… Атлас… Color Overlay… Накладання кольору… Gradient Overlay… Накладання градієнта… @@ -759,6 +764,7 @@ Set shape fill type Установити тип заливки фігури Set shape stroke type Установити тип обведення фігури Set the text color Установити колір тексту + Create warped text Створити деформований текст Only Web Colors Лише вебкольори Overlay Color Колір накладання Overlay Opacity Непрозорість накладання @@ -1041,6 +1047,8 @@ No brush presets Немає наборів пензлів Override Pressure Перевизначити натиск Override Tilt Перевизначити нахил + Override Tilt X Перевизначити нахил X + Override Tilt Y Перевизначити нахил Y Override Rotation Перевизначити обертання Apply the pen's tilt and rotation to the tip shape Застосувати нахил і обертання пера до форми кінчика @@ -1170,6 +1178,7 @@ Clipping Path Відсічний контур New Path Новий контур Duplicate Path Дублювати контур + Rename Path… Перейменувати контур… Delete Path Видалити контур Current Path Поточний контур Vector Mask from Current Path Векторна маска з поточного контуру @@ -1319,6 +1328,8 @@ Keywords Ключові слова Copyright Info URL URL інформації про авторське право Copyright Status Статус авторського права + Copyright Notice Застереження про авторські права + Document Title Назва документа Copyrighted Захищено авторським правом Public Domain Суспільне надбання Unknown Невідомо @@ -1561,7 +1572,7 @@ Click to restore the current colour Клацніть, щоб відновити поточний колір Click with the Note tool to add a note. Клацніть інструментом «Нотатка», щоб додати нотатку. Drag a rectangle around the object Намалюйте прямокутник навколо об'єкта - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Перетягніть на зображенні, щоб додати обмеження; Shift для горизонталі/вертикалі; праве клацання для видалення. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Перетягніть на зображенні, щоб додати обмеження; Shift для горизонталі/вертикалі; клацніть правою кнопкою на обмеженні, щоб змінити або видалити його. Drag the arrow to set the angle, the dots to set the roundness Перетягніть стрілку, щоб установити кут, а точки — округлість Create new pattern from the selection Створити новий візерунок із виділення Create new gradient Створити новий градієнт @@ -2391,6 +2402,7 @@ Quick export format Формат швидкого експорту Quick export location Розташування швидкого експорту Recent file count Кількість нещодавніх файлів + LUT folder Папка LUT Recent files Нещодавні файли Recent fonts Нещодавні шрифти Recover on launch Відновлювати під час запуску @@ -2486,6 +2498,8 @@ GPU acceleration could not continue. Your documents are unchanged. Прискорення GPU не може продовжувати роботу. Ваші документи не змінено. PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft використовує компонувальник зображень CPU. Вікно може й далі використовувати ваш графічний адаптер. Retry GPU requires a restart. Save your work first. Повторна спроба використати GPU потребує перезапуску. Спочатку збережіть роботу. + Retry GPU tries the graphics processor again now. Повторити спробу з GPU — одразу знову спробувати графічний процесор. + GPU acceleration resumed. Прискорення GPU відновлено. Keep Using CPU Продовжити з CPU Retry GPU Повторити спробу з GPU GPU Backend Графічний бекенд @@ -2552,6 +2566,11 @@ cameraRaw Roughness Шорсткість Content-Aware Move Tool Інструмент «Переміщення з урахуванням вмісту» TIFF Options Параметри TIFF Targa Options Параметри Targa + JPEG Options Параметри JPEG + Image Options Параметри зображення + Format Options Різновид формату + Baseline ("Standard") Базовий («Стандартний») + Baseline Optimized Базовий оптимізований 24 bits/pixel 24 біт/піксель 32 bits/pixel 32 біт/піксель Including layers will increase file size. Додавання шарів збільшить розмір файлу. @@ -3743,6 +3762,32 @@ cameraData Lens Об'єктив Mirror Віддзеркалення Output Settings Параметри виводу Output To Вивести в + + Animation Options Параметри анімації + Entire Document Увесь документ + Filter Options Параметри фільтрів + Layer Bounds Межі шару + Layers Panel Options Параметри панелі «Шари» + New Artboard… Нова монтажна область… + New Group… Нова група… + Prevent Auto-Nesting Заборонити автоматичне вкладення + Show Layers Показати шари + Thumbnail Contents Вміст мініатюр + Thumbnail Size Розмір мініатюр + Use Default Masks on Fill Layers Використовувати типові маски для шарів заливки + Expand New Effects Розгортати нові ефекти + Add “copy” to Copied Layers and Groups Додавати «копія» до скопійованих шарів і груп + Preview on canvas when reordering layers Попередній перегляд на полотні під час зміни порядку шарів + Show layer mask badges Показувати позначки масок шарів + +# Smart Filter editors + Blending Options ({filter}) Параметри накладання ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Приховати ефекти шару та смарт-фільтри ({key}-клік: усі шари) + Show the layer's effects and Smart Filters ({key}-click: all layers) Показати ефекти шару та смарт-фільтри ({key}-клік: усі шари) + Wait for the current job to finish. Дочекайтеся завершення поточного завдання. + The document or Smart Filter changed. Reopen its dialog. Документ або смарт-фільтр змінився. Відкрийте його діалог знову. + Opacity must be between 0% and 100%. Непрозорість має бути від 0% до 100%. + Dissolve is approximated: it blends without Photoshop's dither. Розчинення наближене: змішування без дизерингу Photoshop. Loading fonts… Завантаження шрифтів… Response curve Крива відгуку Simulate Pressure Імітувати натиск @@ -3798,6 +3843,10 @@ curves Smooth Згладити Click to place · Alt-click or drag off-canvas to remove Клацніть, щоб розмістити · Alt-клацання або перетягування за межі полотна для видалення No document open Документ не відкрито {n} of {max} sample points {n} з {max} точок вимірювання + +# History panel menu (#2966) + New Snapshot… Новий знімок стану… + History Options… Параметри історії… # Print dialog (#2697) Emulsion Down Емульсією донизу Functions Функції @@ -3832,9 +3881,16 @@ curves Smooth Згладити Light Gray Світло-сірий Object Finder Пошук об’єктів No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels В активному шарі тут немає пікселів — увімкніть «Зразок з усіх шарів» або виберіть шар із пікселями Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Load Contours… Завантажити контури… + Replace Contours… Замінити контури… + Save Contours… Зберегти контури… + Delete Contour Видалити контур + Loaded contours from {file} Контури завантажено з {file} + Saved contours to {file} Контури збережено в {file} Show home recent files Показувати нещодавні файли на головному екрані Clear Brush Controls Очистити параметри пензля Reset All Locked Settings Скинути всі заблоковані параметри @@ -3843,6 +3899,9 @@ curves Smooth Згладити # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Завантажити операції… Save Actions… Зберегти операції… + New Action… Нова операція… + Start Recording Почати запис + Stop Recording Зупинити запис {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Завантажено {n} операцію з {file}|Завантажено {n} операції з {file}|Завантажено {n} операцій з {file} @@ -3874,6 +3933,16 @@ curves Smooth Згладити Sent to {printer} Надіслано на {printer} set in Print Settings… задається в параметрах друку… ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів +# History panel › History Brush source (#2966) + Paints from the History panel's source state Малює з вихідного стану панелі «Історія» + Sets the source for the history brush Задає джерело для пензля історії +# Bevel & Emboss shading controls. + Highlight Mode Режим світлих тонів + Highlight Color Колір світлих тонів + Highlight Opacity Непрозорість світлих тонів + Shadow Mode Режим тіней + Shadow Color Колір тіней + Shadow Opacity Непрозорість тіней # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Використати вбудований профіль ({profile}) Convert to the working space ({profile}) Перетворити в робочий простір ({profile}) @@ -3890,10 +3959,14 @@ curves Smooth Згладити Chisel Hard Жорстке різьблення Chisel Soft М'яке різьблення Open color-less EXR as ACEScg Відкривати EXR без колірного профілю як ACEScg + Do not append "copy" to filename when saving a copy Не додавати «copy» до імені файлу під час збереження копії # View › Guides › right-click a guide Edit Selected Guides Редагувати вибрані напрямні Clear Selected Guides Очистити вибрані напрямні + Bicubic Automatic Бікубічна, автоматично + Bicubic Smoother Бікубічна, гладкіша + Bicubic Sharper Бікубічна, чіткіша Pasting from other apps is unavailable Вставлення з інших застосунків недоступне PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. У цьому сеансі PhotoCraft не має доступу до системного буфера обміну. У Wayland пісочниця Flatpak приховує протокол, через який читаються зображення з інших застосунків. To paste from other apps, start PhotoCraft under XWayland: `{command}` Щоб вставляти з інших застосунків, запустіть PhotoCraft під XWayland: `{command}` @@ -3913,6 +3986,17 @@ curves Smooth Згладити set name назва набору {set} (modified) {set} (змінено) Show file name in title Показувати ім’я файла в заголовку + +# Image › Mode › Bitmap (#5559) + 50% Threshold Поріг 50% + Pattern Dither Візерунковий дизеринг + Diffusion Dither Дифузійний дизеринг + Halftone Screen Півтоновий растр + Custom Pattern Довільний візерунок + Frequency: Лініатура: + Lines/Inch Ліній/дюйм + Lines/Centimeter Ліній/сантиметр + Ellipse Еліпс Accept and Go to Conflict Прийняти й перейти до конфлікту Changes undone. Зміни скасовано. Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього. @@ -3930,9 +4014,11 @@ curves Smooth Згладити GPU Memory Full Пам'ять GPU заповнена The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Шарам потрібно {needed} пам'яті GPU, а займати вони можуть {budget}, тому зміни, що перемальовують увесь документ, виконуються на CPU. Натисніть, щоб задати «Пам'ять GPU». GPU memory budget: {budget} Бюджет пам'яті GPU: {budget} + Graphics memory: {size} Відеопам'ять: {size} This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) + {path}: the path is not valid Unicode; save under another name. {path}: шлях не є коректним Unicode; збережіть під іншою назвою. Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. @@ -3940,6 +4026,13 @@ curves Smooth Згладити Increase Brush Size Збільшити розмір пензля Decrease Brush Hardness Зменшити жорсткість пензля Increase Brush Hardness Збільшити жорсткість пензля + “{name}” already exists. Replace it? «{name}» уже існує. Замінити? + The name in the save dialog was “{name}”. У вікні збереження було вказано назву «{name}». + Previous Brush Попередній пензель + Next Brush Наступний пензель + First Brush Перший пензель + Last Brush Останній пензель + Preview size Preview size # Dynamic color-picker titles and warnings about losing editable layer content. Color Picker (Background Color) Вибір кольору (колір тла) Color Picker (Foreground Color) Вибір кольору (основний колір) @@ -3948,6 +4041,15 @@ curves Smooth Згладити This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Щоб продовжити, потрібно растеризувати цей шар фігури. Після цього його більше не можна буде редагувати як фігуру. Растеризувати фігуру? This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Щоб продовжити, потрібно растеризувати цей смартоб’єкт. Після цього його вміст більше не можна буде редагувати. Растеризувати смартоб’єкт? This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Щоб продовжити, потрібно растеризувати цей шар заливки. Після цього його заливку більше не можна буде редагувати. Растеризувати шар? + Demosaic Демозаїк + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Як відновлюється мозаїка кольорового фільтра камери. Застосовується під час відкриття файлу; попередній перегляд показує метод, з яким файл було відкрито. + AHD (adaptive homogeneity) AHD (адаптивна однорідність) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Хіракава–Паркс: найчистіші краї й найменше кольорової облямівки, найповільніший. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Інтерполяція з корекцією градієнта: різка й швидка, з невеликою кольоровою облямівкою на дрібних деталях. + The fastest and the softest, with colour fringing on fine detail. Найшвидший і найм'якший, з кольоровою облямівкою на дрібних деталях. + Move Up Перемістити вгору + Move Down Перемістити вниз Memory Usage Використання пам’яті Per-document RAM limit (MB) Ліміт RAM на документ (МБ) Installed RAM: {ram} MB; this limit: {percent}% Встановлено RAM: {ram} МБ; цей ліміт: {percent}% @@ -3963,5 +4065,28 @@ curves Smooth Згладити Compression Стиснення Generate Mipmaps Створити MIP-карти +# Color Table (.act) dialog + There is no color table to save. Немає таблиці кольорів для збереження. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Збережено {n} колір у {file}|Збережено {n} кольори у {file}|Збережено {n} кольорів у {file} + # Graphics Adapter preference (#1885) GPU adapter Адаптер GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Спочатку підтвердьте або скасуйте відкритий діалог кроку операції + Pro Medium Gray Середньосіра «Профі» + Solarized Solarized + Breeze Breeze + Pro Light Gray Світло-сіра «Профі» + Pro Light Світла «Профі» + Resize Windows to Fit Підганяти розмір вікон + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Автоматичний напрямок абзацу + Left-to-right paragraph direction Напрямок абзацу зліва направо + Right-to-left paragraph direction Напрямок абзацу справа наліво + Not rendered: this adjustment ({key}) is kept as imported. Не відображається: це коригування ({key}) збережено як було імпортовано. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 503cc1006f..9c7155fe0d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -20,6 +20,10 @@ # (`plural_none` in i18n/mod.rs). +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… Tùy chọn mặt nạ… + Layer Mask Display Options… Tùy chọn hiển thị mặt nạ lớp… + # Channel Options and New Spot Channel (#3761) Channel Color Màu kênh Color Indicates: Màu biểu thị: @@ -870,6 +874,7 @@ Right align text Căn chữ phải Toggle text orientation Đổi hướng chữ Set the text color Đặt màu chữ + Create warped text Tạo chữ uốn Cancel any current edits (Esc) Hủy chỉnh sửa hiện tại (Esc) Commit any current edits ({key}) Áp dụng chỉnh sửa hiện tại ({key}) Author: Tác giả: @@ -1184,6 +1189,8 @@ Tilt X Độ nghiêng X Tilt Y Độ nghiêng Y Override Tilt Ghi đè nghiêng + Override Tilt X Ghi đè độ nghiêng X + Override Tilt Y Ghi đè độ nghiêng Y Override Rotation Ghi đè xoay Override Pressure Ghi đè áp lực Pressure Áp lực @@ -1272,6 +1279,7 @@ Stroke Path Đi nét đường dẫn Save Path Lưu đường dẫn Duplicate Path Nhân bản đường dẫn + Rename Path… Đổi tên đường dẫn… Delete Path Xóa đường dẫn Delete Vector Mask Xóa mặt nạ vector Draw with the Pen tool (P) or make a work path from a selection. Vẽ bằng công cụ Bút (P) hoặc tạo đường dẫn làm việc từ vùng chọn. @@ -1502,6 +1510,7 @@ cameraRaw Shadows Vùng tối Quick export format Định dạng xuất nhanh Quick export location Vị trí xuất nhanh Recent file count Số tệp gần đây + LUT folder Thư mục LUT Recent files Tệp gần đây Recover on launch Khôi phục khi khởi chạy Remember workspace changes Ghi nhớ thay đổi không gian làm việc @@ -1595,6 +1604,8 @@ cameraRaw Shadows Vùng tối PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft đang dùng bộ kết hợp ảnh CPU. Cửa sổ vẫn có thể dùng bộ điều hợp đồ họa của bạn. Retry GPU Thử lại GPU Retry GPU requires a restart. Save your work first. Thử lại GPU cần khởi động lại. Hãy lưu công việc trước. + Retry GPU tries the graphics processor again now. Thử lại GPU sẽ thử lại bộ xử lý đồ họa ngay bây giờ. + GPU acceleration resumed. Đã tiếp tục tăng tốc GPU. Keep Using CPU Tiếp tục dùng CPU # Phase 4.3 — Keyboard Shortcuts and Menus editor @@ -1909,6 +1920,7 @@ document Orientation Hướng trang Preview Xem trước Blend Mode Chế độ hòa trộn Use Global Light Dùng ánh sáng chung + Overprint In chồng Distance Khoảng cách Spread Lan rộng Choke Thu hẹp @@ -2255,7 +2267,7 @@ layers Fill: Độ tô: Delete Timeline Xóa dòng thời gian Deselect All Bỏ chọn tất cả Display Text Hiển thị văn bản - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kéo trên ảnh để thêm ràng buộc; Shift cho ngang/dọc; nhấp chuột phải để xóa. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. Kéo trên ảnh để thêm ràng buộc; Shift cho ngang/dọc; nhấp chuột phải vào ràng buộc để thay đổi hoặc xóa. Export selected measurements (CSV) Xuất các phép đo đã chọn (CSV) Focal Length Tiêu cự Font Size: Cỡ chữ: @@ -2500,6 +2512,11 @@ shape Appearance Diện mạo Snap to Origin Bám vào gốc tọa độ Solid Liền Targa Options Tùy chọn Targa + JPEG Options Tùy chọn JPEG + Image Options Tùy chọn hình ảnh + Format Options Tùy chọn định dạng + Baseline ("Standard") Đường cơ sở ("Chuẩn") + Baseline Optimized Đường cơ sở tối ưu Target: Đích: The layer or its mask changed. Drag the mask onto the Trash again. Lớp hoặc mặt nạ của nó đã thay đổi. Hãy kéo mặt nạ vào Thùng rác lần nữa. There is no crop size or ratio to save Không có kích thước hoặc tỷ lệ cắt để lưu @@ -2985,6 +3002,9 @@ cameraData Lens Ống kính Cooling80 Lạnh80 Cooling82 Lạnh82 Copyright Status Trạng thái bản quyền + Copyright Info URL URL thông tin bản quyền + Copyright Notice Thông báo bản quyền + Document Title Tiêu đề tài liệu Copyrighted Được bảo vệ bản quyền Correct C A Hiệu chỉnh C A Correct Distortion Hiệu chỉnh độ méo @@ -3599,9 +3619,16 @@ curves Smooth Làm mượt Response curve Đường cong phản hồi Object Finder Trình tìm đối tượng No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels Lớp đang hoạt động không có điểm ảnh ở đây — hãy bật Lấy mẫu mọi lớp hoặc chọn một lớp có điểm ảnh Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + Load Contours… Tải đường viền… + Replace Contours… Thay thế đường viền… + Save Contours… Lưu đường viền… + Delete Contour Xóa đường viền + Loaded contours from {file} Đã tải đường viền từ {file} + Saved contours to {file} Đã lưu đường viền vào {file} # Edit › Content-Aware Fill workspace Excluded Area Vùng loại trừ Fill Settings Cài đặt tô @@ -3626,6 +3653,9 @@ curves Smooth Làm mượt # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Nạp hành động… Save Actions… Lưu hành động… + New Action… Hành động mới… + Start Recording Bắt đầu ghi + Stop Recording Dừng ghi {name} (Photoshop) {name} (Photoshop) {message} ({note}) {message} ({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} Đã nạp {n} hành động từ {file} @@ -3645,6 +3675,11 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động +# History panel menu (#2966) + Step Forward Tiến một bước + New Snapshot… Ảnh chụp trạng thái mới… + Clear History Xóa lịch sử + History Options… Tùy chọn lịch sử… Background (Default) Nền (mặc định) Presentation Trình bày # Batch / Image Processor @@ -3661,6 +3696,16 @@ curves Smooth Làm mượt Sent to {printer} Đã gửi tới {printer} set in Print Settings… đặt trong thiết lập in… ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel +# History panel › History Brush source (#2966) + Paints from the History panel's source state Vẽ từ trạng thái nguồn của bảng Lịch sử + Sets the source for the history brush Đặt nguồn cho cọ lịch sử +# Bevel & Emboss shading controls. + Highlight Mode Chế độ vùng sáng + Highlight Color Màu vùng sáng + Highlight Opacity Độ mờ vùng sáng + Shadow Mode Chế độ bóng + Shadow Color Màu bóng + Shadow Opacity Độ mờ bóng # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) Dùng hồ sơ nhúng ({profile}) Convert to the working space ({profile}) Chuyển sang không gian làm việc ({profile}) @@ -3677,10 +3722,14 @@ curves Smooth Làm mượt Chisel Hard Đục cứng Chisel Soft Đục mềm Open color-less EXR as ACEScg Mở EXR không khai báo màu sắc dưới dạng ACEScg + Do not append "copy" to filename when saving a copy Không thêm "copy" vào tên tệp khi lưu bản sao # View › Guides › right-click a guide Edit Selected Guides Sửa đường căn đã chọn Clear Selected Guides Xóa đường căn đã chọn + Bicubic Automatic Bicubic tự động + Bicubic Smoother Bicubic mượt hơn + Bicubic Sharper Bicubic sắc hơn Pasting from other apps is unavailable Không thể dán từ ứng dụng khác PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft không truy cập được bộ nhớ tạm của hệ thống trong phiên này. Trên Wayland, sandbox của Flatpak ẩn giao thức dùng để đọc hình ảnh từ ứng dụng khác. To paste from other apps, start PhotoCraft under XWayland: `{command}` Để dán từ ứng dụng khác, hãy khởi động PhotoCraft dưới XWayland: `{command}` @@ -3700,6 +3749,44 @@ curves Smooth Làm mượt set name tên bộ {set} (modified) {set} (đã sửa đổi) Show file name in title Hiển thị tên tệp trong tiêu đề + +# Image › Mode › Bitmap (#5559) + 50% Threshold Ngưỡng 50% + Pattern Dither Hòa sắc họa tiết + Diffusion Dither Hòa sắc khuếch tán + Halftone Screen Màn bán sắc + Custom Pattern Họa tiết tùy chỉnh + Frequency: Tần số: + Lines/Inch Đường/inch + Lines/Centimeter Đường/centimet + Ellipse Elip + +# Layers panel options and Smart Filter editors + Add “copy” to Copied Layers and Groups Thêm “bản sao” vào lớp và nhóm được sao chép + Animation Options Tùy chọn hoạt ảnh + Entire Document Toàn bộ tài liệu + Expand New Effects Mở rộng hiệu ứng mới + Filter Options Tùy chọn bộ lọc + Layer Bounds Đường biên lớp + Layers Panel Options Tùy chọn bảng Lớp + New Artboard… Bảng vẽ mới… + New Group… Nhóm mới… + Panel Options… Tùy chọn bảng… + Prevent Auto-Nesting Chặn tự lồng + Preview on canvas when reordering layers Xem trước trên Canvas khi sắp xếp lại lớp + Show Layers Hiện các lớp + Show layer mask badges Hiện huy hiệu mặt nạ lớp + Thumbnail Contents Nội dung hình thu nhỏ + Thumbnail Size Kích thước hình thu nhỏ + Use Default Masks on Fill Layers Dùng mặt nạ mặc định cho lớp tô + + Blending Options ({filter}) Tùy chọn hòa trộn ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) Ẩn hiệu ứng và bộ lọc thông minh của lớp ({key}-bấm: tất cả các lớp) + Opacity must be between 0% and 100%. Độ mờ phải nằm trong khoảng từ 0% đến 100%. + Dissolve is approximated: it blends without Photoshop's dither. Hoà tan chỉ được mô phỏng gần đúng: hòa trộn không có dither của Photoshop. + Show the layer's effects and Smart Filters ({key}-click: all layers) Hiện hiệu ứng và bộ lọc thông minh của lớp ({key}-bấm: tất cả các lớp) + The document or Smart Filter changed. Reopen its dialog. Tài liệu hoặc bộ lọc thông minh đã thay đổi. Hãy mở lại hộp thoại của nó. + Wait for the current job to finish. Hãy chờ tác vụ hiện tại hoàn tất. Accept and Go to Conflict Chấp nhận và đến xung đột Changes undone. Đã hoàn tác thay đổi. Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt. @@ -3709,6 +3796,7 @@ curves Smooth Làm mượt {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau) # Free Transform: a new layer while the box is open (#5859) + Reference point location Vị trí điểm tham chiếu Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất. The transform couldn't be applied. Không thể áp dụng phép biến đổi. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -3717,9 +3805,11 @@ curves Smooth Làm mượt GPU Memory Full Bộ nhớ GPU đầy The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Các lớp cần {needed} bộ nhớ GPU và chỉ được dùng {budget}, nên những chỉnh sửa vẽ lại toàn bộ tài liệu sẽ làm trên CPU. Nhấp để đặt Bộ nhớ GPU. GPU memory budget: {budget} Ngân sách bộ nhớ GPU: {budget} + Graphics memory: {size} Bộ nhớ đồ họa: {size} This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) + {path}: the path is not valid Unicode; save under another name. {path}: đường dẫn không phải Unicode hợp lệ; hãy lưu với tên khác. Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. @@ -3727,6 +3817,22 @@ curves Smooth Làm mượt Increase Brush Size Tăng kích thước cọ Decrease Brush Hardness Giảm độ cứng của cọ Increase Brush Hardness Tăng độ cứng của cọ + Demosaic Giải khảm + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. Cách lấp đầy khảm lọc màu của máy ảnh. Áp dụng khi mở tệp; bản xem trước hiển thị phương pháp đã dùng khi mở. + AHD (adaptive homogeneity) AHD (đồng nhất thích ứng) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks: cạnh sạch nhất và ít viền màu nhất, chậm nhất. + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. Nội suy hiệu chỉnh gradient: sắc nét và nhanh, có chút viền màu ở chi tiết nhỏ. + The fastest and the softest, with colour fringing on fine detail. Nhanh nhất và mềm nhất, có viền màu ở chi tiết nhỏ. + “{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế? + The name in the save dialog was “{name}”. Tên trong hộp thoại lưu là “{name}”. + Move Up Di chuyển lên + Move Down Di chuyển xuống + Previous Brush Cọ trước + Next Brush Cọ tiếp theo + First Brush Cọ đầu tiên + Last Brush Cọ cuối cùng + Preview size Preview size Memory Usage Sử dụng bộ nhớ Per-document RAM limit (MB) Giới hạn RAM mỗi tài liệu (MB) Installed RAM: {ram} MB; this limit: {percent}% RAM đã cài: {ram} MB; giới hạn này: {percent}% @@ -3742,5 +3848,28 @@ curves Smooth Làm mượt Compression Nén Generate Mipmaps Tạo mipmap +# Color Table (.act) dialog + There is no color table to save. Không có bảng màu nào để lưu. +@plural Saved {n} color to {file}|Saved {n} colors to {file} Đã lưu {n} màu vào {file} + # Graphics Adapter preference (#1885) GPU adapter Bộ điều hợp GPU +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first Hãy xác nhận hoặc hủy hộp thoại bước hành động đang mở trước + Pro Medium Gray Pro xám vừa + Solarized Solarized + Breeze Breeze + Pro Light Gray Pro xám nhạt + Pro Light Pro sáng + Resize Windows to Fit Đổi cỡ cửa sổ cho vừa ảnh + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction Hướng đoạn văn tự động + Left-to-right paragraph direction Hướng đoạn văn từ trái sang phải + Right-to-left paragraph direction Hướng đoạn văn từ phải sang trái + Not rendered: this adjustment ({key}) is kept as imported. Không được kết xuất: điều chỉnh này ({key}) được giữ nguyên như khi nhập. + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a030a8885f..c1630ad97c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -11,6 +11,10 @@ # Licensed MIT OR Apache-2.0, as the code. # Product and technology names retain their spelling. See docs/localization-zh-hans.md. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… 蒙版选项… + Layer Mask Display Options… 图层蒙版显示选项… + # Channel Options and New Spot Channel (#3761) Channel Color 通道颜色 Color Indicates: 色彩指示: @@ -333,6 +337,7 @@ Blending Options… 混合选项… Bevel & Emboss… 斜面和浮雕… Use Global Light 使用全局光 + Overprint 叠印 Inner Shadow… 内阴影… Inner Glow… 内发光… Satin… 光泽… @@ -818,6 +823,22 @@ Split Warp Vertically 垂直分割变形 Statistics… 统计… Refine Strokes 调整描边 +# Counted status and panel messages +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板 +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已从 {group} 导入 {n} 个画笔 +@plural Played {n} step|Played {n} steps 已播放 {n} 个步骤 +@plural {n} step|{n} steps {n} 个步骤 +@plural Saved for Web: {n} file|Saved for Web: {n} files 已存储为 Web 所用格式:{n} 个文件 +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 个在磁盘上更改过的链接智能对象 +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此图层复合记录的 {n} 个图层已被删除。请更新图层复合,或清除警告。 +@plural {n} plane|{n} planes {n} 个平面 +@plural {n} preset|{n} presets {n} 个预设 +@plural {n} sample point removed|{n} sample points removed 已移除 {n} 个取样点 +@plural {n} slice|{n} slices {n} 个切片 +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(另有 {n} 条画笔预设警告) +@plural {what} with {n} warning|{what} with {n} warnings {what},含 {n} 条警告 + @plural {n} item|{n} items {n} 项 Auto 自动 (print to the printer) (输出到打印机) @@ -998,6 +1019,9 @@ Copper 铜色 Copyright 版权 Copyright and Contact Info 版权与联系信息 + Copyright Info URL 版权信息 URL + Copyright Notice 版权声明 + Document Title 文档标题 Corner Crop Marks 角落裁剪标记 Corner radius 圆角半径 Correction 校正 @@ -1098,7 +1122,7 @@ Done 完成 Dots 点 Drag a rectangle around the object 围绕对象拖出矩形 - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击删除。 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在图像上拖动以添加约束;按 Shift 保持水平或垂直;右键单击约束可更改或删除。 Drag to move the active shape's path or the Work Path 拖动以移动当前形状的路径或工作路径 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 单击:选择锚点 · 拖动:移动锚点、手柄或线段 · {shift}-单击:添加 · {alt}-单击:整个子路径 Draw with the Pen tool (P) or make a work path from a selection. 使用钢笔工具(P)绘制,或从选区创建工作路径。 @@ -1347,6 +1371,8 @@ Override Pressure 覆盖压感 Override Rotation 覆盖旋转 Override Tilt 覆盖倾斜 + Override Tilt X 覆盖 X 倾斜 + Override Tilt Y 覆盖 Y 倾斜 PRESET DETAILS 预设详细信息 PSD PSD Paint Overlay 绘制叠加层 @@ -1432,6 +1458,7 @@ Rename 重命名 Rename Group… 重命名组… Rename Layer… 重命名图层… + Rename Path… 重命名路径… Rename Tool Preset… 重命名工具预设… Rename… 重命名… Resample 重新采样 @@ -1500,6 +1527,7 @@ Set shape fill type 设置形状填充类型 Set shape stroke type 设置形状描边类型 Set the text color 设置文字颜色 + Create warped text 创建变形文字 Shadows 阴影 Shape: 形状: Sharpening 锐化 @@ -1649,6 +1677,7 @@ Brush Pose 画笔姿态 Brush Tip Shape 画笔笔尖形状 Brush Tool 画笔工具 + Pencil Tool 铅笔工具 Build-up 累积 Burn Tool 加深工具 Centimeters 厘米 @@ -1703,6 +1732,7 @@ ExtraBold 特粗 ExtraLight 特细 Eyedropper Tool 吸管工具 + Color Sampler Tool 颜色取样器工具 Fade 渐隐 Fill Opacity 填充不透明度 Film & Video 电影与视频 @@ -1719,6 +1749,7 @@ Highlights from: 高光下限: History Brush Tool 历史记录画笔工具 Horizontal Type Tool 横排文字工具 + Vertical Type Tool 直排文字工具 Horizontal Type Mask Tool 横排文字蒙版工具 Vertical Type Mask Tool 直排文字蒙版工具 Image Size: 图像大小: @@ -1850,9 +1881,11 @@ Tabloid Tabloid 纸 Text Replacement 文字替换 Thin 纤细 + To 到 Traditional (72.27 points/inch) 传统(72.27 点/英寸) Transfer 传递 Triangle Tool 三角形工具 + Up 向上 Unsaved changes 未保存的更改 Uses the foreground and background colours. 使用前景色和背景色。 Watercolour look: lighter interior, darker rims. 水彩效果:内部较浅,边缘较深。 @@ -2007,6 +2040,7 @@ Quick export format 快速导出格式 Quick export location 快速导出位置 Recent file count 最近使用的文件数量 + LUT folder LUT 文件夹 Recent files 最近使用的文件 Recent fonts 最近使用的字体 Recover on launch 启动时恢复 @@ -2298,6 +2332,8 @@ cameraRaw Blending 混合 GPU acceleration could not continue. Your documents are unchanged. GPU 加速无法继续。您的文档未更改。 PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft 正在使用 CPU 合成图像。窗口仍可能使用您的显卡。 Retry GPU requires a restart. Save your work first. 重试 GPU 需要重新启动。请先保存您的工作。 + Retry GPU tries the graphics processor again now. 重试 GPU 会立即再次尝试使用图形处理器。 + GPU acceleration resumed. 已恢复 GPU 加速。 Keep Using CPU 继续使用 CPU Retry GPU 重试 GPU Advanced 高级 @@ -2326,6 +2362,11 @@ cameraRaw Blending 混合 Content-Aware Move Tool 内容感知移动工具 TIFF Options TIFF 选项 Targa Options Targa 选项 + JPEG Options JPEG 选项 + Image Options 图像选项 + Format Options 格式选项 + Baseline ("Standard") 基线(“标准”) + Baseline Optimized 基线已优化 24 bits/pixel 24 位/像素 32 bits/pixel 32 位/像素 Including layers will increase file size. 包含图层会增加文件大小。 @@ -2674,6 +2715,36 @@ cameraData Lens 镜头 Output To 输出到 Duplicate Layer 复制图层 Contract 收缩 + + Animation Options 动画选项 + Entire Document 整个文档 + Filter Options 过滤选项 + Layer Bounds 图层边界 + Layers Panel Options 图层面板选项 + Lock Layers 锁定图层 + New Artboard… 新建画板… + New Group 新建组 + New Group… 新建组… + New Layer… 新建图层… + Panel Options… 面板选项… + Prevent Auto-Nesting 防止自动嵌套 + Show Layers 显示图层 + Thumbnail Contents 缩略图内容 + Thumbnail Size 缩略图大小 + Use Default Masks on Fill Layers 在填充图层上使用默认蒙版 + Expand New Effects 展开新效果 + Add “copy” to Copied Layers and Groups 为复制的图层和组添加“副本” + Preview on canvas when reordering layers 重新排列图层时在画布上预览 + Show layer mask badges 显示图层蒙版标记 + +# Smart Filter editors + Blending Options ({filter}) 混合选项 ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) 隐藏图层效果和智能滤镜 ({key} 单击:所有图层) + Show the layer's effects and Smart Filters ({key}-click: all layers) 显示图层效果和智能滤镜 ({key} 单击:所有图层) + Wait for the current job to finish. 请等待当前任务完成。 + The document or Smart Filter changed. Reopen its dialog. 文档或智能滤镜已更改。请重新打开其对话框。 + Opacity must be between 0% and 100%. 不透明度必须介于 0% 和 100% 之间。 + Dissolve is approximated: it blends without Photoshop's dither. “溶解”为近似效果:混合时没有 Photoshop 的抖动。 Loading fonts… 正在加载字体… Response curve 响应曲线 Tool 工具 @@ -2731,6 +2802,13 @@ curves Smooth 平滑 Click to place · Alt-click or drag off-canvas to remove 单击以放置 · Alt 单击或拖到画布外以移除 No document open 未打开任何文档 {n} of {max} sample points {max} 个取样点中的 {n} 个 + +# History panel menu (#2966) + Step Forward 前进一步 + Step Backward 后退一步 + Clear History 清除历史记录 + New Snapshot… 新建快照… + History Options… 历史记录选项… # Print dialog (#2697) Emulsion Down 药膜朝下 Functions 功能 @@ -2751,9 +2829,16 @@ curves Smooth 平滑 Stop 停止 Object Finder 对象查找程序 No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 当前图层在此处没有像素 — 请打开“对所有图层采样”或选择有像素的图层 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Load Contours… 载入等高线… + Replace Contours… 替换等高线… + Save Contours… 存储等高线… + Delete Contour 删除等高线 + Loaded contours from {file} 已从 {file} 载入等高线 + Saved contours to {file} 已将等高线存储到 {file} Show home recent files 在主屏幕上显示最近的文件 Clear Brush Controls 清除画笔控件 Reset All Locked Settings 复位所有锁定的设置 @@ -2762,6 +2847,9 @@ curves Smooth 平滑 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 载入动作… Save Actions… 存储动作… + New Action… 新建动作… + Start Recording 开始录制 + Stop Recording 停止录制 {name} (Photoshop) {name}(Photoshop) {message} ({note}) {message}({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已从 {file} 载入 {n} 个动作 @@ -2794,6 +2882,16 @@ curves Smooth 平滑 Sent to {printer} 已发送到 {printer} set in Print Settings… 在打印设置中设置… ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 +# History panel › History Brush source (#2966) + Paints from the History panel's source state 从历史记录面板的源状态绘画 + Sets the source for the history brush 设置历史记录画笔的源 +# Bevel & Emboss shading controls. + Highlight Mode 高光模式 + Highlight Color 高光颜色 + Highlight Opacity 高光不透明度 + Shadow Mode 阴影模式 + Shadow Color 阴影颜色 + Shadow Opacity 阴影不透明度 # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) 使用嵌入的配置文件({profile}) Convert to the working space ({profile}) 转换到工作空间({profile}) @@ -2810,6 +2908,7 @@ curves Smooth 平滑 Chisel Hard 雕刻清晰 Chisel Soft 雕刻柔和 Open color-less EXR as ACEScg 将无色彩空间信息的 EXR 以 ACEScg 打开 + Do not append "copy" to filename when saving a copy 存储副本时不在文件名后添加“copy” # Export As metadata and profile options Color Profile 颜色配置文件 EXIF EXIF @@ -2817,6 +2916,10 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 编辑所选参考线 Clear Selected Guides 清除所选参考线 + Make Selection 建立选区 + Bicubic Automatic 双三次(自动) + Bicubic Smoother 双三次(较平滑) + Bicubic Sharper 双三次(较锐利) Pasting from other apps is unavailable 无法从其他应用粘贴 PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此会话中无法访问系统剪贴板。在 Wayland 上,Flatpak 沙盒会隐藏读取其他应用图像所用的协议。 To paste from other apps, start PhotoCraft under XWayland: `{command}` 要从其他应用粘贴,请在 XWayland 下启动 PhotoCraft:`{command}` @@ -2837,6 +2940,19 @@ curves Smooth 平滑 {set} (modified) {set}(已修改) Random 随机 Show file name in title 标题中显示文件名 + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50% 阈值 + Pattern Dither 图案仿色 + Diffusion Dither 扩散仿色 + Halftone Screen 半调网屏 + Custom Pattern 自定图案 + Frequency: 频率: + Lines/Inch 线/英寸 + Lines/Centimeter 线/厘米 + Ellipse 椭圆 + Cross 十字 + Method 方法 Accept and Go to Conflict 接受并转到冲突 Changes undone. 已撤消更改。 Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。 @@ -2846,6 +2962,7 @@ curves Smooth 平滑 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) # Free Transform: a new layer while the box is open (#5859) + Reference point location 参考点位置 Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 The transform couldn't be applied. 无法应用变换。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -2854,9 +2971,11 @@ curves Smooth 平滑 GPU Memory Full GPU 内存已满 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 图层需要 {needed} GPU 内存,但只能占用 {budget},因此重绘整个文档的编辑会在 CPU 上进行。点击以设置 GPU 内存。 GPU memory budget: {budget} GPU 内存预算:{budget} + Graphics memory: {size} 显存:{size} This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) + {path}: the path is not valid Unicode; save under another name. {path}:路径不是有效的 Unicode;请以其他名称存储。 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 @@ -2864,6 +2983,22 @@ curves Smooth 平滑 Increase Brush Size 增大画笔大小 Decrease Brush Hardness 减小画笔硬度 Increase Brush Hardness 增大画笔硬度 + Demosaic 去马赛克 + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填补相机彩色滤镜阵列的马赛克。在打开文件时应用;预览显示打开时所用的方法。 + AHD (adaptive homogeneity) AHD(自适应同质性) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:边缘最干净、色边最少,速度最慢。 + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 梯度校正插值:锐利且快速,细节处有些许色边。 + The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,细节处有色边。 + “{name}” already exists. Replace it? “{name}”已存在。要替换它吗? + The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。 + Move Up 上移 + Move Down 下移 + Previous Brush 上一个画笔 + Next Brush 下一个画笔 + First Brush 第一个画笔 + Last Brush 最后一个画笔 + Preview size Preview size Memory Usage 内存使用 Per-document RAM limit (MB) 每个文档的 RAM 上限 (MB) Installed RAM: {ram} MB; this limit: {percent}% 已安装 RAM:{ram} MB;此上限:{percent}% @@ -2880,5 +3015,28 @@ curves Smooth 平滑 Compression 压缩 Generate Mipmaps 生成 Mipmap +# Color Table (.act) dialog + There is no color table to save. 没有可保存的色表。 +@plural Saved {n} color to {file}|Saved {n} colors to {file} 已将 {n} 种颜色保存到 {file} + # Graphics Adapter preference (#1885) GPU adapter GPU 适配器 +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first 请先确认或取消打开的动作步骤对话框 + Pro Medium Gray 专业中灰 + Solarized Solarized + Breeze Breeze + Pro Light Gray 专业浅灰 + Pro Light 专业浅色 + Resize Windows to Fit 调整窗口大小以适合 + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 自动段落方向 + Left-to-right paragraph direction 从左到右的段落方向 + Right-to-left paragraph direction 从右到左的段落方向 + Not rendered: this adjustment ({key}) is kept as imported. 未渲染:此调整({key})按导入时的原样保留。 + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 25a05a1826..0771327a40 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -19,6 +19,10 @@ # kept. Full-width punctuation (,。:) inside sentences; half-width parentheses and the " · " # separators as in the English. +# Layer Mask Display Options (the layer mask rubylith's colour and opacity) + Mask Options… 遮色片選項… + Layer Mask Display Options… 圖層遮色片顯示選項… + # Channel Options and New Spot Channel (#3761) Channel Color 色版顏色 Color Indicates: 色彩指示: @@ -338,6 +342,7 @@ Blending Options… 混合選項… Bevel & Emboss… 斜角和浮雕… Use Global Light 使用全域光 + Overprint 疊印 Inner Shadow… 內陰影… Inner Glow… 內光暈… Satin… 緞面… @@ -1002,6 +1007,9 @@ Copper 銅色 Copyright 版權 Copyright and Contact Info 版權與聯絡資訊 + Copyright Info URL 版權資訊 URL + Copyright Notice 版權聲明 + Document Title 文件標題 Corner Crop Marks 角落裁切標記 Corner radius 圓角半徑 Correction 校正 @@ -1101,7 +1109,7 @@ Done 完成 Dots 點 Drag a rectangle around the object 在物件周圍拖曳出矩形 - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;按右鍵可刪除。 + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it. 在影像上拖曳以新增限制線;按住 Shift 可限制水平/垂直;在限制線上按右鍵可變更或刪除。 Drag to move the active shape's path or the Work Path 拖曳以移動作用中形狀的路徑或工作路徑 Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath 按一下:選取錨點 · 拖曳:移動錨點、把手或線段 · {shift}-按一下:增加 · {alt}-按一下:整個子路徑 Draw with the Pen tool (P) or make a work path from a selection. 使用筆型工具 (P) 繪製,或從選取範圍建立工作路徑。 @@ -1344,6 +1352,8 @@ Override Pressure 覆寫壓力 Override Rotation 覆寫旋轉 Override Tilt 覆寫傾斜 + Override Tilt X 覆寫傾斜 X + Override Tilt Y 覆寫傾斜 Y PRESET DETAILS 預設集詳細資料 PSD PSD Paint Overlay 繪畫覆蓋 @@ -1429,6 +1439,7 @@ Rename 重新命名 Rename Group… 重新命名群組… Rename Layer… 重新命名圖層… + Rename Path… 重新命名路徑… Rename Tool Preset… 重新命名工具預設集… Rename… 重新命名… Resample 重新取樣 @@ -1496,6 +1507,7 @@ Set shape fill type 設定形狀填滿類型 Set shape stroke type 設定形狀筆畫類型 Set the text color 設定文字顏色 + Create warped text 建立彎曲文字 Shadows 陰影 Shape: 形狀: Sharpening 銳利化 @@ -2000,6 +2012,7 @@ Quick export format 快速匯出格式 Quick export location 快速匯出位置 Recent file count 最近檔案數量 + LUT folder LUT 資料夾 Recent files 最近檔案 Recent fonts 最近使用的字體 Recover on launch 啟動時復原 @@ -2241,6 +2254,8 @@ GPU acceleration could not continue. Your documents are unchanged. GPU 加速無法繼續。您的文件未變更。 PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft 正使用 CPU 合成影像。視窗仍可能使用您的顯示卡。 Retry GPU requires a restart. Save your work first. 重試 GPU 需要重新啟動。請先儲存您的工作。 + Retry GPU tries the graphics processor again now. 重試 GPU 會立即再次嘗試使用圖形處理器。 + GPU acceleration resumed. 已恢復 GPU 加速。 Keep Using CPU 繼續使用 CPU Retry GPU 重試 GPU Advanced 進階 @@ -2296,6 +2311,11 @@ cameraRaw Blending 混合 Content-Aware Move Tool 內容感知移動工具 TIFF Options TIFF 選項 Targa Options Targa 選項 + JPEG Options JPEG 選項 + Image Options 影像選項 + Format Options 格式選項 + Baseline ("Standard") 基線(「標準」) + Baseline Optimized 基線已最佳化 24 bits/pixel 24 位元/像素 32 bits/pixel 32 位元/像素 Including layers will increase file size. 包含圖層會增加檔案大小。 @@ -2644,6 +2664,36 @@ cameraData Lens 鏡頭 Output To 輸出至 Duplicate Layer 複製圖層 Contract 縮減 + + Animation Options 動畫選項 + Entire Document 整份文件 + Filter Options 篩選選項 + Layer Bounds 圖層邊界 + Layers Panel Options 圖層面板選項 + Lock Layers 鎖定圖層 + New Artboard… 新增工作區域… + New Group 新增群組 + New Group… 新增群組… + New Layer… 新增圖層… + Panel Options… 面板選項… + Prevent Auto-Nesting 防止自動巢狀 + Show Layers 顯示圖層 + Thumbnail Contents 縮圖內容 + Thumbnail Size 縮圖大小 + Use Default Masks on Fill Layers 在填色圖層使用預設遮色片 + Expand New Effects 展開新效果 + Add “copy” to Copied Layers and Groups 為複製的圖層和群組加上「副本」 + Preview on canvas when reordering layers 重新排列圖層時在畫布上預覽 + Show layer mask badges 顯示圖層遮色片標記 + +# Smart Filter editors + Blending Options ({filter}) 混合選項 ({filter}) + Hide the layer's effects and Smart Filters ({key}-click: all layers) 隱藏圖層效果和智慧型濾鏡 ({key} 點擊:所有圖層) + Show the layer's effects and Smart Filters ({key}-click: all layers) 顯示圖層效果和智慧型濾鏡 ({key} 點擊:所有圖層) + Wait for the current job to finish. 請等待目前的工作完成。 + The document or Smart Filter changed. Reopen its dialog. 文件或智慧型濾鏡已變更。請重新開啟其對話框。 + Opacity must be between 0% and 100%. 不透明度必須介於 0% 和 100% 之間。 + Dissolve is approximated: it blends without Photoshop's dither. 「溶解」為近似效果:混合時沒有 Photoshop 的仿色。 Loading fonts… 正在載入字體… Response curve 回應曲線 Tool 工具 @@ -2701,6 +2751,13 @@ curves Smooth 平滑 Click to place · Alt-click or drag off-canvas to remove 按一下以放置 · Alt 按一下或拖曳至畫布外以移除 No document open 未開啟任何文件 {n} of {max} sample points {max} 個取樣點中的 {n} 個 + +# History panel menu (#2966) + Step Forward 向前一步 + Step Backward 向後一步 + Clear History 清除步驟記錄 + New Snapshot… 新增快照… + History Options… 步驟記錄選項… # Print dialog (#2697) Emulsion Down 感光乳劑面朝下 Functions 功能 @@ -2736,9 +2793,16 @@ curves Smooth 平滑 Stop 停止 Object Finder 物件尋找器 No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形 + The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels 作用中圖層在此處沒有像素 — 請開啟「取樣全部圖層」或選取有像素的圖層 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Load Contours… 載入等高線… + Replace Contours… 取代等高線… + Save Contours… 儲存等高線… + Delete Contour 刪除等高線 + Loaded contours from {file} 已從 {file} 載入等高線 + Saved contours to {file} 已將等高線儲存到 {file} Show home recent files 在主畫面上顯示最近的檔案 Clear Brush Controls 清除筆刷控制項 Reset All Locked Settings 重設所有鎖定的設定 @@ -2747,6 +2811,9 @@ curves Smooth 平滑 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 載入動作… Save Actions… 儲存動作… + New Action… 新增動作… + Start Recording 開始錄製 + Stop Recording 停止錄製 {name} (Photoshop) {name}(Photoshop) {message} ({note}) {message}({note}) @plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已從 {file} 載入 {n} 個動作 @@ -2779,6 +2846,16 @@ curves Smooth 平滑 Sent to {printer} 已傳送至 {printer} set in Print Settings… 在列印設定中設定… ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 +# History panel › History Brush source (#2966) + Paints from the History panel's source state 從步驟記錄面板的來源狀態繪畫 + Sets the source for the history brush 設定步驟記錄筆刷的來源 +# Bevel & Emboss shading controls. + Highlight Mode 亮部模式 + Highlight Color 亮部顏色 + Highlight Opacity 亮部不透明度 + Shadow Mode 陰影模式 + Shadow Color 陰影顏色 + Shadow Opacity 陰影不透明度 # Embedded Profile Mismatch: profile names beside the choices, Remember this choice Use the embedded profile ({profile}) 使用內嵌描述檔 ({profile}) Convert to the working space ({profile}) 轉換為工作空間 ({profile}) @@ -2795,6 +2872,7 @@ curves Smooth 平滑 Chisel Hard 雕刻清晰 Chisel Soft 雕刻柔和 Open color-less EXR as ACEScg 將無色彩空間資訊的 EXR 以 ACEScg 開啟 + Do not append "copy" to filename when saving a copy 儲存副本時不在檔名後加上「copy」 # Export As metadata and profile options Color Profile 色彩描述檔 EXIF EXIF @@ -2802,6 +2880,10 @@ curves Smooth 平滑 # View › Guides › right-click a guide Edit Selected Guides 編輯所選參考線 Clear Selected Guides 清除所選參考線 + Make Selection 建立選取範圍 + Bicubic Automatic 雙三次 (自動) + Bicubic Smoother 雙三次 (較平滑) + Bicubic Sharper 雙三次 (較銳利) Pasting from other apps is unavailable 無法從其他應用程式貼上 PhotoCraft can't reach the system clipboard in this session. On Wayland, a Flatpak sandbox hides the protocol that images from other apps are read through. PhotoCraft 在此工作階段中無法存取系統剪貼簿。在 Wayland 上,Flatpak 沙盒會隱藏讀取其他應用程式影像所用的協定。 To paste from other apps, start PhotoCraft under XWayland: `{command}` 若要從其他應用程式貼上,請在 XWayland 下啟動 PhotoCraft:`{command}` @@ -2822,6 +2904,19 @@ curves Smooth 平滑 {set} (modified) {set}(已修改) Random 隨機 Show file name in title 標題中顯示檔案名稱 + +# Image › Mode › Bitmap (#5559) + 50% Threshold 50% 臨界值 + Pattern Dither 圖樣混色 + Diffusion Dither 擴散混色 + Halftone Screen 半色調網屏 + Custom Pattern 自訂圖樣 + Frequency: 頻率: + Lines/Inch 線/英吋 + Lines/Centimeter 線/公分 + Ellipse 橢圓 + Cross 十字 + Method 方法 Accept and Go to Conflict 接受並前往衝突 Changes undone. 已還原變更。 Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。 @@ -2831,6 +2926,7 @@ curves Smooth 平滑 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) # Free Transform: a new layer while the box is open (#5859) + Reference point location 參考點位置 Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 The transform couldn't be applied. 無法套用變形。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice @@ -2839,9 +2935,11 @@ curves Smooth 平滑 GPU Memory Full GPU 記憶體已滿 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 圖層需要 {needed} GPU 記憶體,但只能佔用 {budget},因此重繪整份文件的編輯會在 CPU 上進行。按一下以設定 GPU 記憶體。 GPU memory budget: {budget} GPU 記憶體預算:{budget} + Graphics memory: {size} 顯示記憶體:{size} This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) + {path}: the path is not valid Unicode; save under another name. {path}:路徑不是有效的 Unicode;請以其他名稱儲存。 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 @@ -2849,6 +2947,22 @@ curves Smooth 平滑 Increase Brush Size 放大筆刷大小 Decrease Brush Hardness 降低筆刷硬度 Increase Brush Hardness 提高筆刷硬度 + Demosaic 去馬賽克 + How the camera's colour filter mosaic is filled in. It is applied when the file opens; the preview shows the method it was opened with. 如何填補相機彩色濾鏡陣列的馬賽克。在開啟檔案時套用;預覽顯示開啟時所用的方法。 + AHD (adaptive homogeneity) AHD(自適應同質性) + Malvar–He–Cutler Malvar–He–Cutler + Hirakawa–Parks: the cleanest edges and the least colour fringing, the slowest. Hirakawa–Parks:邊緣最乾淨、色邊最少,速度最慢。 + Gradient-corrected interpolation: sharp and fast, with some colour fringing on fine detail. 梯度校正插值:銳利且快速,細節處有些許色邊。 + The fastest and the softest, with colour fringing on fine detail. 最快也最柔和,細節處有色邊。 + “{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎? + The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。 + Move Up 上移 + Move Down 下移 + Previous Brush 上一個筆刷 + Next Brush 下一個筆刷 + First Brush 第一個筆刷 + Last Brush 最後一個筆刷 + Preview size Preview size Memory Usage 記憶體使用量 Per-document RAM limit (MB) 每個文件的 RAM 上限 (MB) Installed RAM: {ram} MB; this limit: {percent}% 已安裝 RAM:{ram} MB;此上限:{percent}% @@ -2865,5 +2979,28 @@ curves Smooth 平滑 Compression 壓縮 Generate Mipmaps 產生 Mipmap +# Color Table (.act) dialog + There is no color table to save. 沒有可儲存的色表。 +@plural Saved {n} color to {file}|Saved {n} colors to {file} 已將 {n} 種顏色儲存至 {file} + # Graphics Adapter preference (#1885) GPU adapter GPU 介面卡 +# Actions: a step dialog holds a suspended recording (#7365) + Confirm or cancel the open action step dialog first 請先確認或取消開啟中的動作步驟對話框 + Pro Medium Gray 專業中灰 + Solarized Solarized + Breeze Breeze + Pro Light Gray 專業淺灰 + Pro Light 專業淺色 + Resize Windows to Fit 調整視窗大小以符合 + + EXR EXR + +# Paragraph direction (TYPE-218-2) + Automatic paragraph direction 自動段落方向 + Left-to-right paragraph direction 由左至右的段落方向 + Right-to-left paragraph direction 由右至左的段落方向 + Not rendered: this adjustment ({key}) is kept as imported. 未算繪:此調整({key})依匯入時的原樣保留。 + +# Print preview image (#8236) + Print preview image Print preview image diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 7fdaf4f7d0..a255fe094b 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -6,6 +6,20 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), ("align-right", include_bytes!("../../../assets/icons/align-right.svg")), + ("align-edge-bottom", include_bytes!("../../../assets/icons/align-edge-bottom.svg")), + ("align-edge-hcenter", include_bytes!("../../../assets/icons/align-edge-hcenter.svg")), + ("align-edge-left", include_bytes!("../../../assets/icons/align-edge-left.svg")), + ("align-edge-right", include_bytes!("../../../assets/icons/align-edge-right.svg")), + ("align-edge-top", include_bytes!("../../../assets/icons/align-edge-top.svg")), + ("align-edge-vcenter", include_bytes!("../../../assets/icons/align-edge-vcenter.svg")), + ("distribute-bottom", include_bytes!("../../../assets/icons/distribute-bottom.svg")), + ("distribute-hcenter", include_bytes!("../../../assets/icons/distribute-hcenter.svg")), + ("distribute-horizontal", include_bytes!("../../../assets/icons/distribute-horizontal.svg")), + ("distribute-left", include_bytes!("../../../assets/icons/distribute-left.svg")), + ("distribute-right", include_bytes!("../../../assets/icons/distribute-right.svg")), + ("distribute-top", include_bytes!("../../../assets/icons/distribute-top.svg")), + ("distribute-vcenter", include_bytes!("../../../assets/icons/distribute-vcenter.svg")), + ("distribute-vertical", include_bytes!("../../../assets/icons/distribute-vertical.svg")), ("app-window", include_bytes!("../../../assets/icons/app-window.svg")), ("artboard", include_bytes!("../../../assets/icons/artboard.svg")), ("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")), @@ -118,6 +132,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("settings", include_bytes!("../../../assets/icons/settings.svg")), ("slash", include_bytes!("../../../assets/icons/slash.svg")), ("slice-knife", include_bytes!("../../../assets/icons/slice-knife.svg")), + ("slice-select", include_bytes!("../../../assets/icons/slice-select.svg")), ("sliders-horizontal", include_bytes!("../../../assets/icons/sliders-horizontal.svg")), ("sparkles", include_bytes!("../../../assets/icons/sparkles.svg")), ("spline", include_bytes!("../../../assets/icons/spline.svg")), @@ -136,6 +151,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("symmetry-vertical", include_bytes!("../../../assets/icons/symmetry-vertical.svg")), ("swatch-book", include_bytes!("../../../assets/icons/swatch-book.svg")), ("text-cursor", include_bytes!("../../../assets/icons/text-cursor.svg")), + ("text-warp", include_bytes!("../../../assets/icons/text-warp.svg")), ("toggle-left", include_bytes!("../../../assets/icons/toggle-left.svg")), ("toggle-right", include_bytes!("../../../assets/icons/toggle-right.svg")), ("trash", include_bytes!("../../../assets/icons/trash.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 37d0b4aeab..bb1bba202b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -109,7 +109,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Crop => "crop", Tool::PerspectiveCrop => "perspective-crop", Tool::Slice => "slice-knife", - Tool::SliceSelect => "square-dashed-mouse-pointer", + Tool::SliceSelect => "slice-select", Tool::Frame => "frame-placeholder", Tool::Gradient => "gradient", Tool::PaintBucket => "paint-bucket", @@ -278,6 +278,29 @@ mod tests { } } + #[test] + fn slice_select_icon_is_its_own() { + // #7859: Slice Select reused Object Selection's dashed square with a pointer. As in + // Photoshop it is the Slice knife with a pointer: unlike Object Selection, and the Slice + // tool's knife plus ink of its own (the pointer). + assert_ne!(tool_icon(Tool::SliceSelect), tool_icon(Tool::ObjectSelection)); + assert_ne!(tool_icon(Tool::SliceSelect), tool_icon(Tool::Slice)); + for size in [14_u32, 28] { + let select = ink(tool_icon(Tool::SliceSelect), size); + assert!(select.iter().any(|&pixel| pixel), "Slice Select must have ink at {size}px"); + let object = ink(tool_icon(Tool::ObjectSelection), size); + let shared = select.iter().zip(&object).filter(|(a, b)| **a && **b).count(); + let union = select.iter().zip(&object).filter(|(a, b)| **a || **b).count(); + assert!(union > 0, "icons must have ink at {size}px"); + let overlap = shared as f32 / union as f32; + assert!(overlap < 0.5, "Slice Select and Object Selection must read as distinct: {overlap:.2} of their ink overlaps at {size}px"); + let slice = ink(tool_icon(Tool::Slice), size); + let own = select.iter().zip(&slice).filter(|(a, b)| **a && !**b).count(); + let total = select.iter().filter(|a| **a).count(); + assert!(own as f32 >= 0.3 * total as f32, "the pointer sets Slice Select apart from Slice: {own} of {total} pixels at {size}px"); + } + } + /// Ink bounding box `(width, height)` of a rasterized mask. fn ink_extent(mask: &[bool], size: u32) -> (usize, usize) { let s = size as usize; diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 3877ca33f3..89dc5e5483 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -194,6 +194,56 @@ fn clicking_outside_a_dialog_keeps_it_open_and_the_canvas_pans_and_zooms() { assert!(h.state().ui.dialogs.is_empty()); } +/// ⌘Space (Ctrl+Space off the Mac) and ⌥Space are the temporary Zoom under an open dialog too, as +/// in Photoshop: a drag on the free canvas is a scrubby zoom about the press point, not a pan, and +/// a click steps the zoom (#6460). +#[test] +fn temporary_zoom_works_under_a_dialog() { + let mut h = harness(); + h.state_mut().ui.extras.rulers = false; + open_dialog(&mut h); + let p = free_canvas(&h); + h.hover_at(p); + h.run_steps(1); + let (z0, doc0) = (zoom(&h), doc_at(&h, p)); + let held = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool, m: Modifiers| { + h.event(egui::Event::ModifiersChanged(m)); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: m }); + h.run_steps(1); + }; + let button = |h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool, m: Modifiers| { + h.event_modifiers(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: m }, m); + h.run_steps(1); + }; + held(&mut h, true, Modifiers::COMMAND); + button(&mut h, p, true, Modifiers::COMMAND); + for i in 1..=6 { + h.hover_at(p + vec2(10.0 * i as f32, 0.0)); + h.run_steps(1); + } + button(&mut h, p + vec2(60.0, 0.0), false, Modifiers::COMMAND); + held(&mut h, false, Modifiers::COMMAND); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let z1 = zoom(&h); + let size = h.state().ui.views[0].doc_size; + assert!((z1 - crate::zoom_tool::scrub_zoom(z0, 60.0, size)).abs() < 1e-4, "⌘Space-drag zooms in: {z0} -> {z1}"); + let doc1 = doc_at(&h, p); + assert!((doc1[0] - doc0[0]).abs() < 0.5 && (doc1[1] - doc0[1]).abs() < 0.5, "the pressed point stays put: {doc0:?} -> {doc1:?}"); + + // ⌥Space-click steps out. + h.hover_at(p); + h.run_steps(1); + held(&mut h, true, Modifiers::ALT); + button(&mut h, p, true, Modifiers::ALT); + button(&mut h, p, false, Modifiers::ALT); + held(&mut h, false, Modifiers::ALT); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(zoom(&h), crate::zoom_levels::step(z1, -1, size)); + assert_eq!(h.state().ui.dialogs.len(), 1); +} + fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) { h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); h.run_steps(1); @@ -503,6 +553,49 @@ fn alt_scroll_zooms_while_a_temporary_tool_is_held() { assert_eq!(h.state().ui.tool, crate::state::Tool::Brush); } +/// #8049 (Windows 11): the Alt key's press arrives, but the modifier state stays without Alt, so +/// every wheel event while it is held reports no modifiers; tapping Ctrl brought Alt back. Until +/// then ⌥ + scroll panned (or, with Zoom with Scroll Wheel on, zoomed) like a plain scroll. +#[test] +fn alt_scroll_zooms_when_the_modifier_state_misses_the_held_alt() { + let alt_key = + |pressed: bool, repeat: bool| egui::Event::Key { key: Key::AltLeft, physical_key: Some(Key::AltLeft), pressed, repeat, modifiers: Modifiers::NONE }; + for zoom_with_wheel in [false, true] { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel); + let p = h.state().last_canvas_rect.center(); + h.hover_at(p); + h.run_steps(2); + let (z0, c0) = (zoom(&h), h.state().ui.views[0].center); + // Alt goes down (and repeats while held); the wheel events carry no modifiers. + h.event(alt_key(true, false)); + h.run_steps(1); + h.event(alt_key(true, true)); + wheel(&h, 1.0, Modifiers::NONE); + h.run_steps(2); + wheel(&h, 1.0, Modifiers::NONE); + h.run_steps(40); + if zoom_with_wheel { + // ⌥ + scroll scrolls instead of zooming. + assert_eq!(zoom(&h), z0, "pref on: ⌥ + scroll never zooms"); + assert!(h.state().ui.views[0].center[1] < c0[1], "pref on: ⌥ + scroll pans: {c0:?} -> {:?}", h.state().ui.views[0].center); + } else { + assert!((zoom(&h) / z0 - 1.1f32.powi(2)).abs() < 1e-3, "two ⌥ notches are ×1.21: {z0} -> {}", zoom(&h)); + } + // Alt released: the wheel does its plain thing again. + h.event(alt_key(false, false)); + h.run_steps(2); + let z1 = zoom(&h); + wheel(&h, -1.0, Modifiers::NONE); + h.run_steps(40); + if zoom_with_wheel { + assert!(zoom(&h) < z1, "pref on: a plain notch zooms out: {z1} -> {}", zoom(&h)); + } else { + assert_eq!(zoom(&h), z1, "a plain scroll after Alt's release doesn't zoom"); + } + } +} + #[test] fn type_tool_alt_arrows_kern_the_pair() { use photocraft_doc::text::Kerning; @@ -524,6 +617,7 @@ fn type_tool_alt_arrows_kern_the_pair() { let metric = photocraft_text::shared().lock().unwrap().pair_kerning(&text(&h), 72.0, 0).unwrap().round(); h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 1, anchor: 1, session: "kern-test".into(), @@ -574,6 +668,7 @@ fn type_tool_alt_arrows_kern_the_pair() { fn edit_type(h: &mut Harness<'static, PhotocraftApp>, layer: u64, anchor: usize, caret: usize) { h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer, + document: None, caret, anchor, session: "format-keys".into(), @@ -792,6 +887,7 @@ fn type_tool_format_keys_survive_hostile_state() { for ed in [None, Some(1)] { app.ui.text_edit = ed.map(|layer| crate::state::TextEdit { layer, + document: None, caret: 4, anchor: 0, session: "x".into(), diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 29da41d481..259eca7615 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -10,8 +10,12 @@ //! shown after a short delay so quick jobs don't flash it. Esc cancels. //! - Files opening in the background get a document tab at once ([`OpenTab`]); its canvas area //! shows the progress, and closing the tab cancels the open. -//! - Saves encode and write on a worker ([`start_save`], #2017) under the modal progress dialog; -//! the document is locked meanwhile, and the save is recorded when the job ends. +//! - Saves encode and write on a worker ([`start_save`], #2017); the document is locked +//! meanwhile, and the save is recorded when the job ends. With Preferences › File Handling › +//! Save in Background on (the default) you keep working while the file is written, the progress +//! in the document's tab and the status bar; off, the save runs in the foreground (#6207): the +//! modal progress dialog shows at once and no shortcut or tool input reaches the app until the +//! file is complete ([`foreground_save`]). //! //! The canvas keeps showing the pre-job state: workers never touch the session's documents. @@ -147,6 +151,9 @@ pub struct SaveJob { /// The job has ended (cancelled, say) but its worker may still be running: kept until it /// returns, so quitting and another save to the same file wait for it. pub ended: bool, + /// Started with Save in Background off: the app waits for it under the modal progress dialog + /// (see [`foreground_save`]). + pub foreground: bool, } /// The shell's job bookkeeping. @@ -301,7 +308,9 @@ pub fn start_save( Ok(Some((path, warnings))) } Started::Job(job) => { - app.jobs.saves.push(SaveJob { job, doc, revision, path, copy, automation, state, ended: false }); + // Preferences › File Handling › Save in Background, read when the save starts. + let foreground = !app.session.prefs().file_handling.save_in_background; + app.jobs.saves.push(SaveJob { job, doc, revision, path, copy, automation, state, ended: false, foreground }); // Control and MCP requests wait on it, as for other jobs. app.jobs.last_started = Some(job); // The status bar's progress readout names the save; clear any old message. @@ -394,9 +403,20 @@ pub fn keep_polling(app: &PhotocraftApp, ctx: &egui::Context) -> bool { true } -/// The job the user is looking at: the open tab in view, the active document's job, or (for -/// the status bar) the newest running job. +/// The save the app is waiting on, while it runs: one started with Preferences › File Handling › +/// Save in Background off (#6207). Like Photoshop's foreground save it blocks the app: the modal +/// progress dialog shows at once ([`dialog`]) and no shortcut or tool reaches the documents +/// until the file is written; anything after it (a close prompt, quitting) waits for it. +pub fn foreground_save(app: &PhotocraftApp) -> Option { + app.jobs.saves.iter().filter(|s| s.foreground && !s.ended).find_map(|s| app.session.job(s.job)) +} + +/// The job the user is looking at: a foreground save, the open tab in view, the active +/// document's job, or (for the status bar) the newest running job. pub fn job_in_view(app: &PhotocraftApp) -> Option { + if let Some(j) = foreground_save(app) { + return Some(j); + } if let Some(j) = app.jobs.focus.and_then(|j| app.session.job(j)) { return Some(j); } @@ -525,7 +545,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { } /// What [`crate::PhotocraftApp::open_bytes`] does after decoding, for a background open's result -/// (`{document, warnings, color}`). +/// (`{document, docId, warnings, color}` or `{held, …}`). fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Value, slot: Option) -> Result<(), String> { let warnings: Vec = v.get("warnings").and_then(Value::as_array).map(|a| a.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default(); @@ -546,8 +566,11 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu crate::open_prompt::arrive(app, held(doc, read_only, v.get("color").cloned(), false)); return Ok(()); } - let mut index = v.get("document").and_then(Value::as_u64).ok_or("the open job returned no document")? as usize; - // An earlier file is waiting on its prompt: this one opens after it, in order. + // poll_jobs applies all ready jobs before the shell handles their events. An earlier + // handler may have moved or closed tabs, so the apply-time index is no longer authority. + let document = v.get("docId").and_then(Value::as_u64).map(DocId).ok_or("the open job returned no document identity")?; + let index = app.session.documents().iter().position(|st| st.doc.id == document).ok_or("the open job's document is missing")?; + // An earlier file is waiting on its prompt: this document waits after it, in order. if crate::open_prompt::waiting(app) && let Some(st) = app.session.close(index) { @@ -556,20 +579,25 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu crate::open_prompt::arrive(app, held(doc, read_only, None, true)); return Ok(()); } - if let Some(slot) = slot - && let Ok(moved) = app.run("document.move", json!({"document": index, "to": slot})) - { - index = moved.get("document").and_then(Value::as_u64).map_or(index, |i| i as usize); + if let Some(slot) = slot { + let _ = app.run("document.move", json!({"document": index, "to": slot})); } - app.session.set_active(index); - after_open(app, name, path, &warnings, None); - Ok(()) + after_open(app, document, name, path, &warnings, None) } -/// What every open does once its document is the active one: remember where it came from, say +/// What every open does for its admitted document: remember where it came from, say /// so, show the import warnings (a raw opens in Camera Raw first, developed from `path` or /// `bytes`) and fire the "Open Document" script events. -pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, warnings: &[String], bytes: Option<&[u8]>) { +pub(crate) fn after_open( + app: &mut PhotocraftApp, + document: DocId, + name: &str, + path: Option<&str>, + warnings: &[String], + bytes: Option<&[u8]>, +) -> Result<(), String> { + let index = app.session.documents().iter().position(|st| st.doc.id == document).ok_or("the opened document is missing")?; + app.session.set_active(index); if let Some(p) = path { app.opened_from(p); } @@ -582,7 +610,7 @@ pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str> app.ui.status_error = false; // A raw opens in Camera Raw first; the dialog says how it was developed. if crate::camera_raw_ui::wants_open_dialog(app, warnings) { - crate::camera_raw_ui::queue_open_dialog(app, name, path, bytes); + crate::camera_raw_ui::queue_open_dialog(app, document, name, path, bytes, warnings)?; notices::io_warnings(app, &opened, &crate::camera_raw_ui::without_develop_note(warnings)); } else { notices::io_warnings(app, &opened, warnings); @@ -590,6 +618,7 @@ pub(crate) fn after_open(app: &mut PhotocraftApp, name: &str, path: Option<&str> // Script events bound to "Open Document". photocraft_engine::automate_cmds::document_opened(&mut app.session); app.sync_views(); + Ok(()) } // ------------------------------------------------------------------------------------- drawing @@ -697,12 +726,20 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// The modal progress window for a job locking the active document (Photoshop shows one for /// long filters): title, status, bar, percentage and elapsed time, Cancel. Appears after -/// [`DIALOG_DELAY_MS`]; Esc cancels (see [`tick`]). +/// [`DIALOG_DELAY_MS`]; Esc cancels (see [`tick`]). A save shows it only when it runs in the +/// foreground (Save in Background off), and then at once, so nothing can be clicked meanwhile; a +/// background save shows its progress in the tab and the status bar instead. pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) { - let Some(j) = app.session.active_job() else { return }; - if j.elapsed_ms < DIALOG_DELAY_MS { - return; - } + let j = match foreground_save(app) { + Some(j) => j, + None => { + let Some(j) = app.session.active_job() else { return }; + if j.elapsed_ms < DIALOG_DELAY_MS || app.jobs.saves.iter().any(|s| s.job == j.id) { + return; + } + j + } + }; let t = Tokens::get(ctx); let mut cancel_it = false; // Photoshop doesn't dim the window behind its progress dialog either. diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 70ce973be0..b9ff0f00a5 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -447,11 +447,15 @@ fn saves_run_in_the_background_and_are_recorded_when_they_end() { let st = h.state().session.active().unwrap(); assert!(st.is_dirty() && st.path.is_none()); assert!(h.state_mut().run("layer.new.layer", json!({})).is_err(), "the saving document is locked"); - // The progress dialog names the save and shows its stage; the status bar reads "Saving 0%" - // until the encoder reports progress (#3389). - assert_eq!(h.query_all_by_label("Saving big.psd").count(), 1); - h.get_by_label("Encoding"); + // Save in Background is on by default: no modal dialog, so the rest of the app stays usable + // (#6207); the status bar reads "Saving 0%" until the encoder reports progress (#3389). + assert!(h.query_by_label("Esc to cancel").is_none() && h.query_by_label("Encoding").is_none(), "no progress dialog"); h.get_by_label("Saving 0%"); + assert!(crate::jobs_ui::foreground_save(h.state()).is_none()); + h.state_mut().ui.tool = crate::state::Tool::Move; + h.key_press(egui::Key::B); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Brush, "shortcuts still work while it saves"); gates.encode.store(true, Ordering::Relaxed); step_until(&mut h, |a| !a.saving()); @@ -465,6 +469,86 @@ fn saves_run_in_the_background_and_are_recorded_when_they_end() { assert!(h.state_mut().run("layer.new.layer", json!({})).is_ok(), "unlocked again"); } +/// Save in Background off (#6207): the same worker writes the file, but the app waits for it +/// like Photoshop's foreground save. The progress dialog shows at once, shortcuts don't reach the +/// app, and a close asked for meanwhile happens only once the file is complete. +#[test] +fn save_in_background_off_blocks_the_app_until_the_file_is_written() { + let mut h = app_harness(); + h.state_mut().run("prefs.set", json!({"path": "fileHandling.saveInBackground", "value": false})).unwrap(); + let gates = Gates { write: Arc::new(AtomicBool::new(true)), ..Gates::default() }; + h.state_mut().services.save_file = Some(gates.save(None)); + h.state_mut().ui.tool = crate::state::Tool::Move; + let r = h.state_mut().save_as(Some("/tmp/photocraft-test/fg.psd".into())).unwrap(); + assert_eq!(r["pending"], true, "the same background job does the work"); + h.run_steps(1); + // The dialog is up from the first frame (no delay), titled with the save, then its stage. + h.get_by_label("Saving fg.psd"); + h.get_by_label("Esc to cancel"); + let t = Instant::now(); + while h.query_by_label("Encoding").is_none() && t.elapsed() < Duration::from_secs(30) { + h.step(); + } + h.get_by_label("Encoding"); + assert!(crate::jobs_ui::foreground_save(h.state()).is_some()); + h.key_press(egui::Key::B); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move, "no shortcut reaches the app while it saves"); + assert!(h.state_mut().run("layer.new.layer", json!({})).is_err(), "the saving document is locked"); + // Quitting now waits for the file. + let st = h.state_mut().session.active_mut().unwrap(); + st.saved_revision = st.revision; + assert!(press_window_close(h.state_mut()), "the close waits for the save"); + assert!(h.state().jobs.close_after_saves); + + gates.encode.store(true, Ordering::Relaxed); + let t = Instant::now(); + while h.state().jobs.close_after_saves && t.elapsed() < Duration::from_secs(30) { + h.step(); + if h.state().jobs.close_after_saves { + assert!(h.query_by_label("Esc to cancel").is_some(), "the dialog stays up until the save ends"); + } + } + assert_eq!(gates.written(), ["/tmp/photocraft-test/fg.psd"], "the file is complete before the close goes on"); + assert!(!h.state().jobs.close_after_saves, "then the held close is repeated"); + h.run_steps(2); + assert!(h.query_by_label("Esc to cancel").is_none(), "the dialog is gone"); + let st = h.state().session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/tmp/photocraft-test/fg.psd")); + assert!(!st.is_dirty()); + h.key_press(egui::Key::B); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Brush, "shortcuts work again"); +} + +/// A failing foreground save reports its error, closes its dialog and leaves the document +/// unsaved, as a background one does; Esc still cancels one that is encoding. +#[test] +fn a_failed_or_cancelled_foreground_save_leaves_the_document_unsaved() { + let mut h = app_harness(); + h.state_mut().run("prefs.set", json!({"path": "fileHandling.saveInBackground", "value": false})).unwrap(); + let gates = Gates::open(); + h.state_mut().services.save_file = Some(gates.save(Some("disk full"))); + h.state_mut().save_as(Some("/tmp/photocraft-test/f.psd".into())).unwrap(); + step_until(&mut h, |a| !a.saving()); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty() && st.path.is_none()); + assert!(noticed(h.state(), "disk full"), "the failure is reported"); + assert!(h.query_by_label("Esc to cancel").is_none(), "the dialog is gone"); + + gates.encode.store(false, Ordering::Relaxed); + h.state_mut().services.save_file = Some(gates.save(None)); + h.state_mut().save_as(Some("/tmp/photocraft-test/g.psd".into())).unwrap(); + h.run_steps(2); + h.key_press(egui::Key::Escape); + step_until(&mut h, |a| !a.saving()); + assert!(gates.written().is_empty(), "nothing written"); + let st = h.state().session.active().unwrap(); + assert!(st.is_dirty() && st.path.is_none()); + assert_eq!(h.state().ui.status, "Cancelled Saving g.psd"); + assert!(crate::jobs_ui::foreground_save(h.state()).is_none()); +} + #[test] fn a_failed_save_or_one_cancelled_while_encoding_leaves_the_document_unsaved() { let mut h = app_harness(); @@ -784,6 +868,60 @@ fn esc_cancels_a_background_free_transform_commit() { assert_eq!(st.doc.layers.len(), before.layers.len()); } +/// Warp the active layer into an arc, then ↩. +fn warp_and_commit(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + crate::transform_tool::begin(app, &ctx).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 40.0, t.rect)); + crate::transform_tool::commit(app); +} + +#[test] +fn a_warp_commit_runs_in_the_background_and_keeps_its_preview() { + let mut h = app_harness(); + let steps = h.state().session.active().unwrap().history.past_len(); + warp_and_commit(&mut h); + let app = h.state(); + let job = crate::transform_tool::committing(app).expect("the commit runs as a job"); + assert!(app.session.job(job).is_some_and(|j| j.label == "Warp")); + assert!(app.ui.transform.is_some() && app.transform_preview.is_some()); + assert_eq!(h.state_mut().run("edit.undo", json!({})).err().as_deref(), Some("Warp is still being applied")); + let t = Instant::now(); + while h.state().session.has_jobs() && t.elapsed() < Duration::from_secs(10) { + h.step(); + } + h.run_steps(2); + let app = h.state(); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the session ends with the job"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1); + assert_eq!(st.history.undo_label(), Some("Warp")); + // The arc bulges the layer past its old edges. + let b = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_ne!(b, photocraft_geom::Rect::new(0, 0, 320, 240), "the layer was warped"); +} + +#[test] +fn esc_cancels_a_background_warp_commit() { + let mut h = app_harness(); + let steps = h.state().session.active().unwrap().history.past_len(); + let before = h.state().session.active().unwrap().doc.clone(); + warp_and_commit(&mut h); + let job = crate::transform_tool::committing(h.state()).expect("the commit runs as a job"); + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(3); + let app = h.state(); + assert!(app.session.job(job).is_none()); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps); + let id = st.active_layer.unwrap(); + let px = |d: &photocraft_doc::Document| d.layer(id).unwrap().surface().unwrap().read_region(photocraft_geom::Rect::new(0, 0, 320, 240)); + assert_eq!(px(&st.doc), px(&before), "the layer is untouched"); +} + /// #4559: a background open (the desktop app's) also warns, when its document arrives, about /// type in a font that isn't installed. #[test] @@ -795,7 +933,161 @@ fn background_open_warns_about_missing_fonts() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); // What the open job leaves behind: the decoded document in the session, then its result. let (index, _) = app.session.open_document(doc, None); - super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "warnings": []}), None).unwrap(); + let id = app.session.documents()[index].doc.id; + super::finish_open(&mut app, "poster.psd", None, &json!({"document": index, "docId": id.0, "warnings": []}), None).unwrap(); let want = vec!["font 'NoSuchFamily4559' is not installed; text may render using a fallback face".to_string()]; assert!(app.ui.notices.last().is_some_and(|n| n.title.contains("poster.psd") && n.lines == want), "{:?}", app.ui.notices); } + +fn identity_app() -> PhotocraftApp { + let mut session = photocraft_engine::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.active_mut().unwrap().path = Some("accepted-control.pcraft".into()); + assert!(session.active().unwrap().is_dirty()); + PhotocraftApp::new(session, crate::Services::default()) +} + +fn identity_png(profile: Option) -> Vec { + let app = identity_app(); + let mut doc = (*app.session.active().unwrap().doc).clone(); + doc.icc_profile = profile.map(|profile| profile.profile().to_bytes()); + photocraft_io::export(&doc, "synthetic.png", &Default::default()).unwrap().bytes +} + +/// Complete the real decoder/apply path without delivering its result to the UI yet. This is +/// the same boundary as a poll that applies all ready jobs before handling their UI events. +fn completed_identity_open(app: &mut PhotocraftApp, name: &str, bytes: Vec) -> Value { + match app.session.start_open(name, photocraft_engine::jobs::OpenSource::Bytes(Arc::new(bytes))).unwrap() { + Started::Done(result) => result, + Started::Job(job) => app.session.wait_job(job).unwrap(), + } +} + +struct IdentityControl { + doc: Arc, + path: Option, + revision: u64, + saved_revision: u64, + history: usize, + redo: bool, +} + +impl IdentityControl { + fn capture(app: &PhotocraftApp) -> Self { + let state = app.session.active().unwrap(); + Self { + doc: state.doc.clone(), + path: state.path.clone(), + revision: state.revision, + saved_revision: state.saved_revision, + history: state.history.past_len(), + redo: state.history.can_redo(), + } + } + + fn assert_untouched(&self, app: &PhotocraftApp) { + let state = app.session.documents().iter().find(|state| state.doc.id == self.doc.id).expect("the accepted control remains open"); + assert!(Arc::ptr_eq(&state.doc, &self.doc), "the accepted control's pixels and document data must not change"); + assert_eq!(state.path, self.path, "an import must not rebind another document's save path"); + assert_eq!( + (state.revision, state.saved_revision, state.history.past_len(), state.history.can_redo()), + (self.revision, self.saved_revision, self.history, self.redo) + ); + assert!(state.is_dirty(), "the unrelated document must still need saving"); + } +} + +/// Exercise the consumer separately from the engine result-schema regression: the identity is +/// observed from the real imported document, then supplied even on the old producer. +fn identified_result(app: &PhotocraftApp, mut result: Value) -> (Value, photocraft_doc::DocId) { + let id = app.session.documents()[result["document"].as_u64().unwrap() as usize].doc.id; + result["docId"] = json!(id.0); + (result, id) +} + +#[test] +fn open_identity_survives_tab_reordering() { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + let result = completed_identity_open(&mut app, "imported.png", identity_png(None)); + let (result, imported) = identified_result(&app, result); + let old_index = result["document"].as_u64().unwrap() as usize; + app.session.move_document(old_index, 0).unwrap(); + assert_eq!(app.session.documents()[old_index].doc.id, control.doc.id); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, None).unwrap(); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, imported); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("imported.png")); +} + +#[test] +fn open_identity_survives_an_earlier_tab_closing() { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + app.session.execute("file.new", json!({"width": 2, "height": 2})).unwrap(); + let result = completed_identity_open(&mut app, "imported.png", identity_png(None)); + let (result, imported) = identified_result(&app, result); + app.session.close(1).unwrap(); + app.session.set_active(0); + super::finish_open(&mut app, "imported.png", Some("imported.png"), &result, Some(0)).unwrap(); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, imported); + assert_eq!(app.session.documents()[0].doc.id, imported, "the requested destination slot applies to the correct document"); +} + +#[test] +fn open_identity_rejects_closed_missing_and_invalid_identities() { + for identity in [None, Some(Value::Null), Some(json!("not-an-id")), Some(json!(-1)), Some(json!(u64::MAX)), Some(json!(1.5))] { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + let result = completed_identity_open(&mut app, "closed.png", identity_png(None)); + let (mut result, imported) = identified_result(&app, result); + app.session.close(result["document"].as_u64().unwrap() as usize).unwrap(); + // A reused/stale index must never become permission to finish another document. + result["document"] = json!(0); + match identity { + None => { + result.as_object_mut().unwrap().remove("docId"); + } + Some(value) => result["docId"] = value, + } + assert!(super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, Some(0)).is_err(), "invalid identity was accepted: {result}"); + control.assert_untouched(&app); + assert_eq!(app.session.active().unwrap().doc.id, control.doc.id); + assert_eq!(app.session.documents().len(), 1); + result["docId"] = json!(imported.0); + assert!( + super::finish_open(&mut app, "closed.png", Some("closed.png"), &result, None).is_err(), + "a closed identity must not fall back to the old tab index" + ); + control.assert_untouched(&app); + } +} + +#[test] +fn open_identity_keeps_same_poll_results_in_the_profile_queue() { + let mut app = identity_app(); + let control = IdentityControl::capture(&app); + app.session.execute("edit.colorSettings", json!({"askOnMismatch": true, "askOnMissing": false, "policyRgb": "preserve"})).unwrap(); + let mismatched = identity_png(photocraft_cms::Builtin::from_id("adobe-rgb-compat")); + let prompt = completed_identity_open(&mut app, "profile.png", mismatched); + assert!(prompt["held"].as_u64().is_some()); + let first = completed_identity_open(&mut app, "first.png", identity_png(None)); + let (first, first_id) = identified_result(&app, first); + let second = completed_identity_open(&mut app, "second.png", identity_png(None)); + let (second, second_id) = identified_result(&app, second); + // All engine applies have finished. Handling the first ordinary result removes its tab, + // shifting the second result before its UI callback runs. + super::finish_open(&mut app, "profile.png", Some("profile.png"), &prompt, None).unwrap(); + super::finish_open(&mut app, "first.png", Some("first.png"), &first, None).unwrap(); + super::finish_open(&mut app, "second.png", Some("second.png"), &second, None).unwrap(); + assert_eq!(app.held_opens.iter().map(|held| held.name.as_str()).collect::>(), ["profile.png", "first.png", "second.png"]); + assert_eq!(app.held_opens.iter().skip(1).map(|held| held.doc.id).collect::>(), [first_id, second_id]); + assert_eq!(app.session.documents().len(), 1, "both completed imports wait behind the profile prompt"); + control.assert_untouched(&app); + assert_eq!(crate::open_prompt::answer(&mut app, None).unwrap()["cancelled"], 3); + assert!(app.held_opens.is_empty()); + control.assert_untouched(&app); +} diff --git a/crates/ui-egui/src/jpeg_options_ui.rs b/crates/ui-egui/src/jpeg_options_ui.rs new file mode 100644 index 0000000000..3066efe9b2 --- /dev/null +++ b/crates/ui-egui/src/jpeg_options_ui.rs @@ -0,0 +1,373 @@ +//! JPEG Options: the quality and format File › Save, Save As and Save a Copy ask for before writing +//! a JPEG, as Photoshop's JPEG Options does. Quality is Photoshop's 0–12 scale (Low 0–4, Medium +//! 5–7, High 8–9, Maximum 10–12), mapped to the encoder's 1–100 by +//! `photocraft_engine::file_cmds::jpeg_quality`; Format Options are Baseline ("Standard"), Baseline +//! Optimized and Progressive. Matte, Scans and the size preview aren't offered yet. Re-saving an +//! opened JPEG starts at the file's own quality, as File › Save keeps it (#4032). +//! +//! The save is parked in [`Prompt`] while the modal is up (egui is immediate-mode) and written +//! once answered. Cancel drops the save. Scripts skip the prompt by passing `quality` or +//! `jpegFormat`. +//! +//! Any save can ask: check [`wants_prompt`], then [`park`] (which starts where +//! [`Start::for_active`] says) or [`park_at`] with a [`Start`] of its own, and return. The answer +//! is written by [`confirm`] and journalled as the `record` command passed to `park`: with its +//! path, `quality` (0–12, left out when the file's own quality was kept) and `jpegFormat`, or +//! without the path for a plain `file.save`, which writes whichever file an action is played on +//! ([`plain_save_step`]). + +use photocraft_codecs::JpegFormat; +use serde_json::Value; + +use crate::{ExportSettings, PhotocraftApp}; + +/// The highest quality on Photoshop's JPEG scale. +pub const MAX_QUALITY: u8 = 12; + +/// A JPEG Options answer, from the prompt or from a command's `quality` and `jpegFormat`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JpegChoice { + /// Photoshop's 0–12 scale; `None` keeps the quality the save would use anyway (the opened + /// file's own, else the encoder's default). + pub quality: Option, + pub format: JpegFormat, +} + +impl JpegChoice { + /// `settings` writing this choice. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + ExportSettings { + jpeg_quality: self.quality.map(|q| photocraft_engine::file_cmds::jpeg_quality(f64::from(q))).or(settings.jpeg_quality), + jpeg_format: self.format, + ..settings + } + } + + /// Adds `quality` and `jpegFormat` to a recorded save step, so replaying it doesn't ask again. + pub fn record(self, step: &mut Value) { + if let Some(q) = self.quality { + step["quality"] = q.into(); + } + step["jpegFormat"] = self.format.name().into(); + } +} + +/// The JPEG Options a command or control call gives (`"quality": 0..12`, `"jpegFormat"`), which +/// skip the prompt. Neither given: `None`. A quality that isn't a number from 0 to 12, or a +/// format other than the three names, is an error. +pub fn choice_param(params: &Value, cmd: &str) -> Result, String> { + let format = photocraft_engine::file_cmds::jpeg_format_param(params, cmd).map_err(|e| e.to_string())?; + let quality = match params.get("quality") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_f64() + .filter(|q| (0.0..=f64::from(MAX_QUALITY)).contains(q)) + .map(|q| q.round() as u8) + .ok_or_else(|| format!("{cmd}: `quality` must be a number from 0 to 12 (Photoshop's JPEG scale), got {v}"))?, + ), + }; + Ok((quality.is_some() || format.is_some()).then(|| JpegChoice { quality, format: format.unwrap_or_default() })) +} + +/// The params of a recorded plain `file.save` (which writes whichever file an action is played +/// on): none, unless JPEG Options changed something, so that a replay writes it too without +/// asking. `quality` is left out when the file's own quality was kept, and `jpegFormat` when it +/// is Baseline, the default. +pub fn plain_save_step(choice: Option) -> Value { + let mut step = Value::Object(Default::default()); + if let Some(c) = choice { + if let Some(q) = c.quality { + step["quality"] = q.into(); + } + if c.format != JpegFormat::Baseline { + step["jpegFormat"] = c.format.name().into(); + } + } + step +} + +/// The JPEG Options in a recorded save step, which replays without asking. A `file.saveACopy` +/// step from the engine command keeps its params as they were given, and the engine clamps a +/// `quality` to 0–12 and ignores one that isn't a number, so the replay reads it the same way. A +/// format other than the three names is still an error, as it is for the engine. +pub fn recorded_choice(params: &Value, cmd: &str) -> Result, String> { + let format = photocraft_engine::file_cmds::jpeg_format_param(params, cmd).map_err(|e| e.to_string())?; + let quality = params.get("quality").and_then(Value::as_f64).filter(|q| q.is_finite()).map(|q| q.clamp(0.0, f64::from(MAX_QUALITY)).round() as u8); + Ok((quality.is_some() || format.is_some()).then(|| JpegChoice { quality, format: format.unwrap_or_default() })) +} + +/// Photoshop's name for a quality, in the UI language: Low, Medium, High or Maximum. +pub fn quality_label(q: u8) -> &'static str { + match q { + 0..=4 => tl!("Low"), + 5..=7 => tl!("Medium"), + 8..=9 => tl!("High"), + _ => tl!("Maximum"), + } +} + +/// The 0–12 quality nearest an encoder quality (1–100), the inverse of the engine's mapping. +pub fn quality_from_percent(q: u32) -> u8 { + let q = f64::from(q.clamp(1, 100)); + ((q - 1.0) / 99.0 * f64::from(MAX_QUALITY)).round() as u8 +} + +/// Where the prompt starts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Start { + /// Quality on the 0–12 scale. + pub quality: u8, + /// The opened file's own encoder quality (1–100) that `quality` was rounded from. Left on + /// that step, OK writes it exactly and records no `quality`, so a replay keeps it too. + pub own_quality: Option, + pub format: JpegFormat, +} + +impl Start { + /// Starts at an opened JPEG's own encoder quality (1–100; out of range is clamped). + pub fn own(quality: u8) -> Self { + let q = quality.clamp(1, 100); + Start { quality: quality_from_percent(u32::from(q)), own_quality: Some(q), format: JpegFormat::Baseline } + } + + /// Starts at an encoder quality (1–100) that isn't the file's own, such as a preference. + pub fn percent(quality: u32) -> Self { + Start { quality: quality_from_percent(quality), own_quality: None, format: JpegFormat::Baseline } + } + + /// For the active document: its opened JPEG's own quality (`Document::file_encoding`), or + /// else the Export preferences' JPEG quality (the one Quick Export uses). + pub fn for_active(app: &PhotocraftApp) -> Self { + match app.session.active().and_then(|st| st.doc.file_encoding.jpeg_quality) { + Some(q) => Start::own(q), + None => Start::percent(app.session.prefs().export.jpeg_quality), + } + } +} + +/// A save waiting on the JPEG Options answer. +pub struct Prompt { + /// The document that requested the save, even if tabs change while this is open. + doc: photocraft_doc::DocId, + /// Where the document goes. + pub path: String, + /// Quality, 0–12. + pub quality: u8, + pub format: JpegFormat, + /// See [`Start::own_quality`]. + pub own_quality: Option, + /// Save a Copy: the document keeps its path and unsaved changes. + pub copy: bool, + /// The step the answered save is recorded as, so an action being recorded keeps it: + /// `file.saveAs` for Save As (#2032), `file.saveACopy` for Save a Copy (#4373), and + /// `file.save` (with no params) for File › Save writing the document's own file (#3531). + pub record: &'static str, +} + +/// `true` when `path` names a JPEG (`.jpg`, `.jpeg`, `.jpe`, …). +pub fn is_jpeg_path(path: &str) -> bool { + photocraft_engine::file_cmds::extension(path).and_then(|e| photocraft_codecs::from_extension(&e)) == Some(photocraft_codecs::Format::Jpeg) +} + +/// Whether saving the active document to `path` asks for JPEG Options. +pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool { + is_jpeg_path(path) && app.session.active().is_some() +} + +/// Parks the save of the active document to `path` behind the prompt, starting at +/// [`Start::for_active`]. A prompt already waiting is never replaced. +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { + let start = Start::for_active(app); + park_at(app, path, copy, record, start) +} + +/// [`park`], starting at `start`. +pub fn park_at(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str, start: Start) -> Result<(), String> { + if app.save_options_open() { + return Err(crate::SAVE_OPTIONS_OPEN.into()); + } + let doc = app.active_doc_id()?; + let Start { quality, own_quality, format } = start; + app.jpeg_options = Some(Prompt { doc, path, quality: quality.min(MAX_QUALITY), format, own_quality, copy, record }); + Ok(()) +} + +pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = &mut app.jpeg_options else { return }; + let (mut ok, mut cancel) = (false, false); + let (mut quality, mut format) = (p.quality, p.format); + let modal = egui::Modal::new(egui::Id::new("jpeg-options")).show(ctx, |ui| { + ui.set_max_width(360.0); + ui.label(egui::RichText::new(tl!("JPEG Options")).font(crate::theme::semibold(15.0))); + ui.add_space(4.0); + crate::widgets::hairline(ui); + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Image Options")); + ui.horizontal(|ui| { + ui.label(tl!("Quality")); + ui.add(egui::DragValue::new(&mut quality).range(0..=MAX_QUALITY).speed(0.1)); + // Photoshop's presets: picking one sets the quality it starts at. + egui::ComboBox::from_id_salt("jpeg-quality-preset").selected_text(quality_label(quality)).show_ui(ui, |ui| { + for q in [3, 5, 8, 10] { + if ui.selectable_label(quality_label(quality) == quality_label(q), quality_label(q)).clicked() { + quality = q; + } + } + }); + }); + ui.add(egui::Slider::new(&mut quality, 0..=MAX_QUALITY).show_value(false)); + ui.add_space(8.0); + crate::widgets::section_label(ui, tl!("Format Options")); + ui.radio_value(&mut format, JpegFormat::Baseline, tl!("Baseline (\"Standard\")")); + ui.radio_value(&mut format, JpegFormat::Optimized, tl!("Baseline Optimized")); + ui.radio_value(&mut format, JpegFormat::Progressive, tl!("Progressive")); + ui.add_space(12.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 10.0; + ok = crate::widgets::primary_button(ui, tl!("OK"), 84.0).clicked(); + cancel = crate::widgets::secondary_button(ui, tl!("Cancel"), 84.0).clicked(); + }); + }); + cancel |= modal.should_close(); + p.quality = quality.min(MAX_QUALITY); + p.format = format; + // A focused button takes Enter as its click without consuming the key: Enter on Cancel cancels. + if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { + confirm(app, ctx); + } else if cancel { + app.jpeg_options = None; + app.ui.status = tl!("Save cancelled").into(); + app.ui.status_error = false; + } +} + +/// OK: writes the waiting save with the prompt's quality and format, and journals it. +pub fn confirm(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(p) = app.jpeg_options.take() else { return }; + // Left where it started, an opened JPEG keeps its own quality exactly rather than the nearest + // step on the 0–12 scale (95 would be written as 92). The recorded step then leaves the + // quality out, so a replay keeps the file's own quality too, as File › Save does (#4032). + let own = p.own_quality.filter(|&q| quality_from_percent(u32::from(q)) == p.quality); + let choice = JpegChoice { quality: own.is_none().then_some(p.quality), format: p.format }; + let step = if p.record == "file.save" { + // A plain Save writes whichever file the action is played on (#3531), with the answer. + plain_save_step(Some(choice)) + } else { + let mut step = serde_json::json!({"path": p.path}); + choice.record(&mut step); + step + }; + let mut settings = choice.apply(app.save_settings()); + if own.is_some() { + settings.jpeg_quality = own; + } + app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy, (p.record, step)); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn jpeg_names() { + for p in ["a.jpg", "dir/sub/a.JPEG", "C:\\x\\y.jpe", "a.jpg.jpg"] { + assert!(is_jpeg_path(p), "{p}"); + } + for p in ["a.jpg.psd", ".jpg", "jpg", "a.png", "a.jp2", ""] { + assert!(!is_jpeg_path(p), "{p}"); + } + } + + #[test] + fn choice_params() { + let base = JpegFormat::Baseline; + assert_eq!(choice_param(&json!({}), "file.saveAs"), Ok(None)); + assert_eq!(choice_param(&json!({"quality": null, "jpegFormat": null}), "file.saveAs"), Ok(None)); + assert_eq!(choice_param(&json!({"quality": 12}), "file.saveAs"), Ok(Some(JpegChoice { quality: Some(12), format: base }))); + assert_eq!(choice_param(&json!({"quality": 7.6}), "file.saveAs"), Ok(Some(JpegChoice { quality: Some(8), format: base }))); + assert_eq!(choice_param(&json!({"jpegFormat": "progressive"}), "file.saveAs"), Ok(Some(JpegChoice { quality: None, format: JpegFormat::Progressive }))); + for bad in [ + json!({"quality": 13}), + json!({"quality": -1}), + json!({"quality": 1e300}), + json!({"quality": "8"}), + json!({"quality": true}), + json!({"quality": [8]}), + json!({"jpegFormat": "Optimized"}), + json!({"jpegFormat": 1}), + json!({"quality": 8, "jpegFormat": ""}), + ] { + assert!(choice_param(&bad, "file.saveAs").is_err(), "{bad}"); + } + } + + #[test] + fn recorded_steps_read_like_the_engine() { + let base = JpegFormat::Baseline; + assert_eq!(recorded_choice(&json!({"path": "a.jpg"}), "file.saveACopy"), Ok(None)); + assert_eq!(recorded_choice(&json!({"quality": 90}), "file.saveACopy"), Ok(Some(JpegChoice { quality: Some(12), format: base }))); + assert_eq!(recorded_choice(&json!({"quality": -3}), "file.saveACopy"), Ok(Some(JpegChoice { quality: Some(0), format: base }))); + assert_eq!(recorded_choice(&json!({"quality": "8"}), "file.saveACopy"), Ok(None), "a quality that isn't a number is ignored"); + assert_eq!( + recorded_choice(&json!({"quality": 7.6, "jpegFormat": "optimized"}), "file.saveAs"), + Ok(Some(JpegChoice { quality: Some(8), format: JpegFormat::Optimized })) + ); + assert!(recorded_choice(&json!({"jpegFormat": "fast"}), "file.saveACopy").is_err()); + } + + #[test] + fn quality_scale() { + assert_eq!([0, 4, 5, 7, 8, 9, 10, 12].map(quality_label), ["Low", "Low", "Medium", "Medium", "High", "High", "Maximum", "Maximum"]); + // The prompt's starting quality maps back to the encoder quality it came from, near enough. + for q in 0..=MAX_QUALITY { + assert_eq!(quality_from_percent(u32::from(photocraft_engine::file_cmds::jpeg_quality(f64::from(q)))), q); + } + assert_eq!((quality_from_percent(0), quality_from_percent(85), quality_from_percent(1000)), (0, 10, 12)); + } + + #[test] + fn starts() { + assert_eq!(Start::own(95), Start { quality: 11, own_quality: Some(95), format: JpegFormat::Baseline }); + assert_eq!(Start::own(0), Start { quality: 0, own_quality: Some(1), format: JpegFormat::Baseline }); + assert_eq!(Start::own(255).own_quality, Some(100)); + assert_eq!(Start::percent(85), Start { quality: 10, own_quality: None, format: JpegFormat::Baseline }); + assert_eq!(Start::percent(u32::MAX).quality, MAX_QUALITY); + } + + /// The desktop and web exporters write through `ExportSettings::io_options`: the format picked + /// in JPEG Options reaches the encoder there, not just the settings. + #[test] + fn the_answer_reaches_the_encoder() { + let doc = photocraft_doc::Document::with_background( + "x", + photocraft_doc::Size::new(16, 16), + photocraft_doc::ColorMode::Rgb, + photocraft_doc::SampleType::U8, + photocraft_doc::Color::WHITE, + ); + let write = |format| { + let settings = JpegChoice { quality: Some(10), format }.apply(ExportSettings::default()); + photocraft_io::export(&doc, "a.jpg", &settings.io_options()).unwrap().bytes + }; + // Start of frame: 0xFFC0 baseline, 0xFFC2 progressive. Scan data stuffs its 0xFF bytes, and + // this small white image's tables and headers hold neither pair, so only the frame marker matches. + let sof = |b: &[u8], m: u8| b.windows(2).any(|w| w == [0xFF, m]); + let (base, prog) = (write(JpegFormat::Baseline), write(JpegFormat::Progressive)); + assert!(sof(&base, 0xC0) && !sof(&base, 0xC2), "baseline"); + assert!(sof(&prog, 0xC2) && !sof(&prog, 0xC0), "progressive"); + } + + #[test] + fn choices_apply_and_record() { + let c = JpegChoice { quality: Some(10), format: JpegFormat::Optimized }; + let s = c.apply(ExportSettings::default()); + assert_eq!((s.jpeg_quality, s.jpeg_format), (Some(84), JpegFormat::Optimized)); + let mut step = json!({"path": "a.jpg"}); + c.record(&mut step); + assert_eq!(step, json!({"path": "a.jpg", "quality": 10, "jpegFormat": "optimized"})); + assert_eq!(choice_param(&step, "file.saveAs"), Ok(Some(c)), "a recorded step replays the same answer"); + let unset = JpegChoice { quality: None, format: JpegFormat::Progressive }.apply(ExportSettings { jpeg_quality: Some(95), ..ExportSettings::default() }); + assert_eq!(unset.jpeg_quality, Some(95), "no quality keeps the quality the save had"); + } +} diff --git a/crates/ui-egui/src/lasso_ui.rs b/crates/ui-egui/src/lasso_ui.rs index 1f3f7adba2..72f486378e 100644 --- a/crates/ui-egui/src/lasso_ui.rs +++ b/crates/ui-egui/src/lasso_ui.rs @@ -42,6 +42,11 @@ pub fn active(app: &PhotocraftApp) -> bool { app.drag.as_ref().is_some_and(|d| owns(d.tool) && d.lasso.is_some()) } +/// How far from the last vertex a click still counts as that same vertex clicked again (points +/// on screen): a hand's jitter, not an aiming radius -- a vertex placed further away than this is +/// the next vertex of the outline, however short the segment (#7629). +const SAME_POINT: f64 = 3.0; + /// Between polygonal clicks, Backspace/right-click retracts the last fixed vertex. /// Do not affect a freehand stroke whose primary button is still held. pub fn waiting_for_vertex(app: &PhotocraftApp) -> bool { @@ -154,13 +159,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool match ev { ToolEvent::Down { .. } => { if active(app) { - // A click on the first point, or on the last one again, closes the outline. - let reach = 8.0 / app.point_zoom().max(0.01) as f64; - let near = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) < reach; - let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start) || d.points.last().is_some_and(|l| near([l[0], l[1]])))); + // A click on the first point, or on the last one again, closes the outline. Aiming + // at the first point is a grab; clicking the last one again is the same click + // repeated, so it has only to allow for a pixel of hand jitter. Sharing the grab + // radius made every segment shorter than it impossible to draw, because the click + // meant to place the next vertex closed the outline instead (#7629). + let zoom = app.point_zoom().max(0.01) as f64; + let near = |q: [f64; 2], reach: f64| (q[0] - p[0]).hypot(q[1] - p[1]) < reach / zoom; + let again = |d: &Drag| d.points.last().is_some_and(|l| near([l[0], l[1]], SAME_POINT)); + let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start, 8.0) || again(d))); if close { // A path closes on its start point (a selection outline always closes). - if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && near(d.start)) { + if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && near(d.start, 8.0)) { let (start, n) = (d.start, d.points.len()); append(d, start); if let Some(lasso) = d.lasso.as_mut().filter(|_| d.points.len() > n) { @@ -197,12 +207,21 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } ToolEvent::Move { .. } | ToolEvent::Up { .. } => { let up = matches!(ev, ToolEvent::Up { .. }); + let zoom = app.point_zoom(); let Some(d) = app.drag.as_mut().filter(|d| d.tool == tool) else { return true }; + d.track(mods); let Some(lasso) = &d.lasso else { return true }; let (down, polygonal, previous) = (lasso.down, lasso.polygonal, lasso.cursor); + let p = if down && tool == Tool::FreeformPen { + let last = d.points.last().map_or(d.start, |pt| [pt[0], pt[1]]); + let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, p, mods.shift, zoom); + [x, y] + } else { + p + }; if d.reposition { // Move relative to the rubber-band endpoint, not the last fixed vertex. - let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let last = d.points.last().map_or(d.start, |pt| [pt[0], pt[1]]); d.shift_to([last[0] + p[0] - previous[0], last[1] + p[1] - previous[1]]); } else if down { // While the button is down the outline follows the pointer (freehand), Alt or diff --git a/crates/ui-egui/src/lasso_ui/tests.rs b/crates/ui-egui/src/lasso_ui/tests.rs index 6484ed03c7..71a5b68777 100644 --- a/crates/ui-egui/src/lasso_ui/tests.rs +++ b/crates/ui-egui/src/lasso_ui/tests.rs @@ -306,6 +306,21 @@ fn clicking_the_last_point_again_closes_the_outline() { assert!(app.session.active().unwrap().doc.selection.as_ref().unwrap().sample_channel(150, 100, 0) > 0.9); } +#[test] +fn shift_drag_constrains_freeform_pen_to_a_straight_line() { + let mut app = app(); + app.ui.tool = Tool::FreeformPen; + let shift = Modifiers::SHIFT; + event(&mut app, "down", 10.0, 60.0, shift); + for (x, y) in [(30.0, 63.0), (50.0, 55.0), (80.0, 68.0), (100.0, 52.0)] { + event(&mut app, "move", x, y, shift); + } + let pts = &app.drag.as_ref().unwrap().points; + assert!(pts.len() >= 2, "{pts:?}"); + let y0 = pts[0][1]; + assert!(pts.iter().all(|p| (p[1] - y0).abs() < 1e-6), "⇧ keeps the freehand segment horizontal: {pts:?}"); +} + /// ⌘ (Ctrl) with the Lasso is the Move tool for the drag: through the real canvas, a ⌘-drag outside /// the selection (here: without one) moves the layer and draws no outline (`canvas::command_moves_layer`). #[test] diff --git a/crates/ui-egui/src/layer_filter_ui_tests.rs b/crates/ui-egui/src/layer_filter_ui_tests.rs index 16400ae120..aeeb63e7cc 100644 --- a/crates/ui-egui/src/layer_filter_ui_tests.rs +++ b/crates/ui-egui/src/layer_filter_ui_tests.rs @@ -17,6 +17,7 @@ fn session() -> photocraft_engine::Session { s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); s.execute("layer.new.layer", json!({"name": "Paper"})).unwrap(); s.execute("layer.groupLayers", json!({"name": "Sheets"})).unwrap(); + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); let paper = layer(&s.active().unwrap().doc, "Paper"); s.execute("layer.select", json!({"layer": paper})).unwrap(); let ink = s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap()["layer"].as_u64().unwrap(); diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index beb3e9bc32..a26b2f1b32 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -77,6 +77,8 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { LayerContent::Shape(_) => v.push(Some(("Rasterize Layer", "layer.rasterize.shape"))), LayerContent::Smart(_) => { v.push(Some((tl!("Edit Contents"), "layer.smartObjects.editContents"))); + v.push(Some((tl!("Replace Contents…"), "layer.smartObjects.replaceContents"))); + v.push(Some((tl!("Relink to File…"), "layer.smartObjects.relinkToFile"))); v.push(Some((tl!("Convert to Layers"), "layer.smartObjects.convertToLayers"))); v.push(Some(("Rasterize Layer", "layer.rasterize.smartObject"))); } @@ -158,8 +160,7 @@ pub fn show(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, on_set: bo } /// The menu of `l`'s layer-mask thumbnail (#4575), in Photoshop's order. It acts on `l`, which it -/// selects first like the row menu. Photoshop's Mask Options… (the overlay colour) has no -/// command yet, so it is left out. +/// selects first like the row menu. pub fn show_mask(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, actions: &mut Vec<(String, Value)>) { let Some(mask) = &l.mask else { return }; crate::widgets::menu_scroll(ui, |ui| { @@ -189,6 +190,13 @@ pub fn show_mask(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, actio actions.push(("select.selectAndMask".into(), Value::Null)); ui.close(); } + // Layer Mask Display Options: the rubylith's colour and opacity (`channel.options`). + if ui.button(tl!("Mask Options…")).clicked() { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + actions.push(("ui.maskTarget".into(), json!(true))); + actions.push(("channel.options".into(), Value::Null)); + ui.close(); + } }); } @@ -521,6 +529,15 @@ mod tests { assert!(sel.sample_channel(10, 30, 0) > 0.99, "the selection is kept"); assert!(sel.sample_channel(80, 30, 0) > 0.99, "the clicked layer's mask is added"); assert!(sel.sample_channel(40, 30, 0) < 0.01, "nothing else is selected"); + // Mask Options…: Layer Mask Display Options (the rubylith's colour and opacity). + let (thumbs, _) = crate::mask_thumbs_ui::recorded(&h.ctx, masked.0).expect("mask thumbnail drawn"); + let at = thumbs.iter().find(|(k, _)| *k == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center(); + click(&mut h, at, PointerButton::Secondary); + let at = h.get_by_label("Mask Options…").rect().center(); + click(&mut h, at, PointerButton::Primary); + let d = h.state().ui.dialogs.last().expect("Mask Options… opens a dialog"); + assert!(crate::channel_options_ui::owns(&d.fields) && d.fields["channel"] == json!("mask"), "{:?}", d.fields); + assert_eq!(d.fields["__label"], json!("Layer Mask Display Options…")); } #[test] @@ -574,7 +591,10 @@ mod tests { s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); let ids: Vec<_> = entries(&layer(&s), false, false).into_iter().flatten().map(|e| e.1).collect(); let edit = ids.iter().position(|id| *id == "layer.smartObjects.editContents").expect("Edit Contents entry"); - assert_eq!(ids[edit + 1..edit + 3], ["layer.smartObjects.convertToLayers", "layer.rasterize.smartObject"]); + assert_eq!( + ids[edit + 1..edit + 5], + ["layer.smartObjects.replaceContents", "layer.smartObjects.relinkToFile", "layer.smartObjects.convertToLayers", "layer.rasterize.smartObject"] + ); } /// Right-clicking inside a multi-layer selection offers one Rasterize item for the whole @@ -592,7 +612,14 @@ mod tests { assert!(ids(false).contains(&"layer.smartObjects.editContents"), "a single smart object keeps its items"); let multi = ids(true); assert!(multi.contains(&"layer.rasterize.layer")); - for single in ["layer.rasterize.smartObject", "layer.rasterize.shape", "layer.smartObjects.editContents", "layer.smartObjects.convertToLayers"] { + for single in [ + "layer.rasterize.smartObject", + "layer.rasterize.shape", + "layer.smartObjects.editContents", + "layer.smartObjects.replaceContents", + "layer.smartObjects.relinkToFile", + "layer.smartObjects.convertToLayers", + ] { assert!(!multi.contains(&single), "{single} acts on one layer, not on a selection"); } let label = entries(&smart, true, false).into_iter().flatten().find(|e| e.1 == "layer.rasterize.layer").unwrap().0; diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index 57335a897b..55852aeea1 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -125,7 +125,7 @@ const LINK_W: f32 = 20.0; pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let mut run: Vec<(String, Value)> = Vec::new(); if let Some(s) = layer.surface() { - let b = app.cached_bounds(layer.id.0, s); + let b = app.layer_frame_bounds(layer, s); let u = LenUnit::of(app); let (dw, dh) = app.session.active().map_or((1.0, 1.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height))); if section(ui, "transform", tl!("Transform")) { @@ -176,7 +176,10 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } match &layer.content { LayerContent::Text(_) => crate::type_tool::type_properties(app, ui), - LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id), + LayerContent::Shape(_) => { + crate::vector_ui::shape_properties(app, ui, layer.id); + crate::mask_props_ui::shape_properties(app, ui, layer); + } _ => {} } // Re-read: a section above may have changed the layer (e.g. point to paragraph type). diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 4bed6a7cc4..0658a797bc 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -9,7 +9,7 @@ use egui::{Align2, FontId, Galley, Painter, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; use photocraft_color::BlendMode; -use photocraft_doc::Layer; +use photocraft_doc::{Adjustment, Layer, LayerContent}; use serde_json::{Value, json}; use std::sync::Arc; @@ -30,9 +30,12 @@ pub const MIN_NAME_W: f32 = 64.0; /// A right-hand row indicator. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Indicator { + /// An adjustment PhotoCraft keeps as imported but cannot render (#6541). + Unsupported, Lock, FxTriangle, Fx, + SmartFilters, Link, Blend, } @@ -42,6 +45,8 @@ pub enum Indicator { pub struct RowRects { pub layer: u64, pub row: Rect, + /// Visible layer thumbnail; None when thumbnails are disabled. + pub thumbnail: Option, /// The name as painted (after truncation); `None` when there was no room for it. pub name: Option, pub indicators: Vec<(Indicator, Rect)>, @@ -68,14 +73,33 @@ pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(In (out, name_right) } +/// The PSD key of the adjustment `l` holds when PhotoCraft keeps it as imported but cannot +/// render it. +pub fn unsupported_key(l: &Layer) -> Option<&str> { + match &l.content { + LayerContent::Adjustment(Adjustment::Unsupported { psd_key, .. }) => Some(psd_key), + _ => None, + } +} + /// Width the always-shown indicators of `l` take at the row's right (blend label excluded). pub fn reserved_width(l: &Layer) -> f32 { let mut w = RIGHT_PAD; + if unsupported_key(l).is_some() { + w += ICON_W + GAP; + } if l.locks.any() { w += ICON_W + GAP; } + let smart = crate::smart_ui::has_filters(l); + if !l.effects.items.is_empty() || smart { + w += TRIANGLE_W + GAP; + } if !l.effects.items.is_empty() { - w += TRIANGLE_W + 1.0 + 13.0 + GAP; + w += 13.0 + GAP; + } + if smart { + w += ICON_W + GAP; } if l.link_group.is_some() { w += ICON_W + GAP; @@ -130,15 +154,25 @@ pub fn indicators( let fx_font = theme::semibold(11.0); let blend_font = FontId::proportional(10.5); let has_fx = !l.effects.items.is_empty(); + let has_smart = crate::smart_ui::has_filters(l); let show_blend = l.blend != BlendMode::Normal && l.blend != BlendMode::PassThrough; let fx_galley = has_fx.then(|| painter.layout_no_wrap("fx".into(), fx_font, t.text_dim)); let blend_galley = show_blend.then(|| painter.layout_no_wrap(tl!(l.blend.label()).into(), blend_font, t.text_faint)); let mut items = Vec::new(); + let unsupported = unsupported_key(l); + if unsupported.is_some() { + items.push((Indicator::Unsupported, ICON_W)); + } if locked { items.push((Indicator::Lock, ICON_W)); } - if let Some(g) = &fx_galley { + if has_fx || has_smart { items.push((Indicator::FxTriangle, TRIANGLE_W)); + } + if has_smart { + items.push((Indicator::SmartFilters, ICON_W)); + } + if let Some(g) = &fx_galley { items.push((Indicator::Fx, g.size().x)); } if l.link_group.is_some() { @@ -152,6 +186,12 @@ pub fn indicators( let cy = row.center().y; for &(kind, r) in &rects { match kind { + Indicator::Unsupported => { + icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "triangle-alert", 12.0, t.warning); + let key = unsupported.unwrap_or_default(); + let tip = crate::i18n::fmt(tl!("Not rendered: this adjustment ({key}) is kept as imported."), &[("key", key)]); + ui.interact(r, ui.id().with(("row-unsupported", l.id.0)), Sense::hover()).on_hover_text(tip); + } Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint), Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link-2", 12.0, t.text_faint), Indicator::Fx => { @@ -164,6 +204,11 @@ pub fn indicators( painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_faint); } } + Indicator::SmartFilters => { + let enabled = matches!(&l.content, photocraft_doc::LayerContent::Smart(sm) if sm.filters_enabled); + crate::smart_ui::paint_badge(painter, r.center(), if enabled { t.icon } else { t.text_faint }); + ui.interact(r, ui.id().with(("smart-filters-badge", l.id.0)), Sense::hover()).on_hover_text(tl!("Smart Filters")); + } Indicator::FxTriangle => { let resp = ui.interact(r, ui.id().with(("fx-disclosure", l.id.0)), Sense::click()); paint_triangle(painter, r.center(), fx_open, if resp.hovered() { t.text } else { t.icon }); @@ -173,7 +218,11 @@ pub fn indicators( actions.push(("layer.setEffectsExpanded".into(), json!({"layer": l.id.0, "expanded": !fx_open, "all": all}))); } let (verb, name) = (if fx_open { tl!("Collapse") } else { tl!("Expand") }, l.name.clone()); - let tip = if fx_open { + let tip = if has_smart && fx_open { + tl!("Hide the layer's effects and Smart Filters ({key}-click: all layers)") + } else if has_smart { + tl!("Show the layer's effects and Smart Filters ({key}-click: all layers)") + } else if fx_open { tl!("Hide the layer's effects ({key}-click: all layers)") } else { tl!("Show the layer's effects ({key}-click: all layers)") @@ -206,10 +255,60 @@ pub fn label(painter: &Painter, x: f32, cy: f32, right: f32, text: &str, font: F Some(r) } -/// The Layers panel's own items at the top of its panel menu (Photoshop's flyout). +/// Photoshop-style Layers flyout commands. The dock owns tab-group controls, not this menu. pub fn panel_menu(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui) { - let can = app.session.is_enabled("layer.setExpanded"); - if ui.add_enabled(can, egui::Button::new(tl!("Collapse All Groups"))).clicked() { + use crate::layers_panel_ui::run_menu_command; + use crate::state::LayersPanelDialog; + + let has_doc = app.session.active().is_some(); + if ui.add_enabled(has_doc, egui::Button::new(tl!("New Layer…"))).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::NewLayer(String::new())); + ui.close(); + } + if ui.add_enabled(has_doc, egui::Button::new(tl!("New Group…"))).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::NewGroup(String::new())); + ui.close(); + } + ui.separator(); + run_menu_command(app, ui, tl!("Flatten Image"), "layer.flattenImage"); + ui.separator(); + if ui.add_enabled(app.session.is_enabled("layer.lockLayers"), egui::Button::new(tl!("Lock Layers…"))).clicked() { + app.ui.layers_panel_dialog = Some(LayersPanelDialog::LockLayers { transparency: false, pixels: false, position: false, artboard: false, all: true }); + ui.close(); + } + run_menu_command(app, ui, tl!("Rename Layer…"), "layer.renameLayer"); + let any_hidden = app.session.active().is_some_and(|st| st.selected_layers().iter().any(|id| st.doc.layer(*id).is_some_and(|l| !l.visible))); + if any_hidden { + run_menu_command(app, ui, tl!("Show Layers"), "layer.showLayers"); + } else { + run_menu_command(app, ui, tl!("Hide Layers"), "layer.hideLayers"); + } + ui.separator(); + run_menu_command(app, ui, tl!("New Artboard…"), "layer.new.artboard"); + ui.separator(); + ui.menu_button(tl!("Filter Options"), |ui| { + let mut show = app.ui.layers_panel_options.show_filters; + if ui.checkbox(&mut show, tl!("Show")).changed() { + app.ui.layers_panel_options.show_filters = show; + crate::layers_panel_ui::persist_options(app); + ui.close(); + } + }); + // Animation Options control Photoshop's frame-unification buttons. PhotoCraft's Layers + // panel does not expose those buttons; do not offer nonfunctional toggles. + ui.add_enabled_ui(false, |ui| { + ui.menu_button(tl!("Animation Options"), |_ui| {}); + }); + if ui.button(tl!("Panel Options…")).clicked() { + let mut options = app.ui.layers_panel_options.clone(); + options.thumbnail_contents = crate::layers_panel_ui::thumbnail_contents(app); + app.ui.layers_panel_dialog = Some(LayersPanelDialog::PanelOptions(options)); + ui.close(); + } + ui.separator(); + // Preserve PhotoCraft's existing group disclosure action when expanding the flyout. + let can_collapse = app.session.is_enabled("layer.setExpanded"); + if ui.add_enabled(can_collapse, egui::Button::new(tl!("Collapse All Groups"))).clicked() { if let Err(e) = app.run("layer.setExpanded", json!({"all": true, "expanded": false})) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index 6ec1241b46..4fdac69716 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -37,6 +37,8 @@ fn busy() -> photocraft_engine::Session { s.execute("layer.layerStyle.dropShadow", json!({"layer": g})).unwrap(); prev = Some(g); } + // Group Layers creates collapsed groups (#5571); these tests start with every group open. + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); s } @@ -106,6 +108,26 @@ fn long_names_never_run_under_the_indicators() { } } +/// A layer inside a group sits one step right of the group's row, so the hierarchy shows: its +/// name used to start where the group's did. Top-level layers stay where they were. +#[test] +fn a_groups_layers_indent_past_the_group() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let child = s.execute("layer.new.layer", json!({"name": "Child"})).unwrap()["layer"].as_u64().unwrap(); + let group = s.execute("layer.groupLayers", json!({"name": "Group"})).unwrap()["layer"].as_u64().unwrap(); + // Group Layers creates the group collapsed; open it so the panel lists its layers. + s.execute("layer.setExpanded", json!({"layer": group, "expanded": true})).unwrap(); + let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap(); + let h = harness(s, 1.0, "promedium", 290.0); + let rows = recorded(&h.ctx); + let left = |id: u64| rows.iter().find(|r| r.layer == id).and_then(|r| r.name).map(|n| n.left()).unwrap_or_else(|| panic!("layer {id} drawn")); + let step = crate::layer_tree_ui::TRIANGLE_W; + assert!((left(child) - left(group) - step).abs() < 0.5, "child {} vs group {}", left(child), left(group)); + assert!((left(group) - left(top) - step).abs() < 0.5, "a top-level layer keeps its place: {} vs group {}", left(top), left(group)); +} + +/// A primary click at `at`: hover, press and release a frame apart, then let the panel settle. fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) { h.hover_at(at); h.run_steps(1); @@ -537,6 +559,35 @@ fn press_drag_release_on_the_fx_button_opens_that_style() { assert!(effects.iter().any(|e| e["kind"] == json!("dropShadow")), "on Drop Shadow: {effects:?}"); } +/// Integration regression: a display preference must resize the *actual painted* +/// thumbnails and row hitboxes, not only its Preview dialog. +#[test] +fn panel_thumbnail_sizes_change_real_row_geometry() { + use crate::state::LayerThumbnailSize; + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Photo"})).unwrap(); + session.execute("edit.fill", json!({"color": "#9acb32"})).unwrap(); + let layer = session.active().unwrap().active_layer.unwrap(); + let mut h = harness(session, 1.0, "promedium", 290.0); + for (size, expected_thumb, expected_row) in [ + (LayerThumbnailSize::None, 0.0, 23.0), + (LayerThumbnailSize::Small, 18.0, 32.0), + (LayerThumbnailSize::Medium, 24.0, 32.0), + (LayerThumbnailSize::Large, 48.0, 56.0), + ] { + h.state_mut().ui.layers_panel_options.thumbnail_size = size; + h.run_steps(5); + let found = recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).expect("visible layer row"); + assert!((found.row.height() - expected_row).abs() < 0.1, "{size:?}: row {}", found.row.height()); + match found.thumbnail { + Some(rect) => assert!((rect.width() - expected_thumb).abs() < 0.1, "{size:?}: thumbnail {}", rect.width()), + None => assert_eq!(expected_thumb, 0.0, "{size:?} should display a thumbnail"), + } + assert!(found.name.is_some(), "{size:?}: layer name still visible"); + } +} + /// One layer per lock, each with only that lock set (#4158). fn one_lock_each() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) { let mut s = photocraft_engine::Session::new(); diff --git a/crates/ui-egui/src/layer_shortcut_tests.rs b/crates/ui-egui/src/layer_shortcut_tests.rs index 96ab2520dc..df936d86f3 100644 --- a/crates/ui-egui/src/layer_shortcut_tests.rs +++ b/crates/ui-egui/src/layer_shortcut_tests.rs @@ -188,3 +188,104 @@ fn command_click_on_a_thumbnail_loads_transparency_except_on_the_background() { click(&mut h, shape, Modifiers::COMMAND); assert_eq!(bounds(&h), Some(photocraft_geom::Rect::new(100, 50, 220, 150)), "a hidden layer's transparency loads too"); } + +/// #6827: a custom binding on PageUp/PageDown fires (it was saved as `Pageup`, which matched no +/// key), and one saved that way by an earlier version still parses. +#[test] +fn custom_page_up_and_page_down_bindings_fire() { + for (binding, key, modifiers) in [ + ("Shift+PageUp", Key::PageUp, Modifiers::SHIFT), + ("Cmd+Shift+PageUp", Key::PageUp, Modifiers::COMMAND | Modifiers::SHIFT), + ("Cmd+Alt+PageDown", Key::PageDown, Modifiers::COMMAND | Modifiers::ALT), + ] { + let mut app = app(); + app.run("edit.fill", json!({"contents": "black"})).unwrap(); + app.run("edit.keyboardShortcuts", json!({"set": {"edit.transform.flipHorizontal": binding}, "removeConflicts": true})).unwrap(); + let before = app.session.journal.len(); + press(&mut app, key, modifiers); + let ran: Vec<&str> = app.session.journal[before..].iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ran, ["edit.transform.flipHorizontal"], "{binding}"); + } + assert_eq!(crate::shortcuts::parse("Shift+Pageup"), Some(egui::KeyboardShortcut::new(Modifiers::SHIFT, Key::PageUp))); +} + +/// A click on the empty space below the last row of the Layers panel deselects every layer, as +/// in Photoshop (#8100); a click on a row still selects it. +#[test] +fn a_click_below_the_layers_deselects_them() { + use egui::{Event, PointerButton, pos2, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + let selected = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().selected_layers(); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(top)]); + let rows = crate::layer_row_ui::recorded(&h.ctx); + let bottom = rows.iter().map(|r| r.row.bottom()).fold(f32::MIN, f32::max); + let row = rows.iter().find(|r| r.layer == top).unwrap().row; + click(&mut h, pos2(row.center().x, bottom + 40.0)); + assert!(selected(&h).is_empty(), "the empty space deselects every layer"); + assert_eq!(h.state().session.active().unwrap().active_layer, None); + // Past the double-click window, on the row as it is now drawn. + h.run_steps(40); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == top).unwrap().row; + click(&mut h, pos2(row.left() + 46.0, row.center().y)); + assert_eq!(selected(&h), vec![photocraft_doc::LayerId(top)], "a row still selects"); +} + +/// Over a thumbnail ⌘-click would load, the pointer is a hand with the selection mode's badge: +/// none for a new selection, + with ⇧, − with ⌥, × with ⇧⌥ (#7824). Without ⌘, and over the +/// Background (which has nothing to load), it stays the plain pointer. +#[test] +fn command_over_a_thumbnail_shows_the_load_cursor() { + use egui::{CursorIcon, Event, pos2, vec2}; + + use crate::tool_feedback::Badge; + assert_eq!(crate::panels::thumb_load_badge(Modifiers::COMMAND), None); + assert_eq!(crate::panels::thumb_load_badge(Modifiers::COMMAND | Modifiers::SHIFT), Some(Badge::Add)); + assert_eq!(crate::panels::thumb_load_badge(Modifiers::COMMAND | Modifiers::ALT), Some(Badge::Subtract)); + assert_eq!(crate::panels::thumb_load_badge(Modifiers::COMMAND | Modifiers::SHIFT | Modifiers::ALT), Some(Badge::Intersect)); + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let background = s.active().unwrap().active_layer.unwrap().0; + let shape = s.execute("layer.new.layer", json!({"name": "Shape"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let cursor = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, layer: u64, modifiers: Modifiers| { + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == layer).unwrap().row; + h.hover_at(pos2(row.left() + 46.0, row.center().y)); + h.event(Event::ModifiersChanged(modifiers)); + h.run_steps(2); + let icon = h.output().platform_output.cursor_icon; + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); + icon + }; + for m in [Modifiers::COMMAND, Modifiers::COMMAND | Modifiers::SHIFT, Modifiers::COMMAND | Modifiers::ALT] { + assert_eq!(cursor(&mut h, shape, m), CursorIcon::PointingHand, "{m:?}"); + } + assert_ne!(cursor(&mut h, shape, Modifiers::NONE), CursorIcon::PointingHand, "a plain click selects the layer"); + assert_ne!(cursor(&mut h, background, Modifiers::COMMAND), CursorIcon::PointingHand, "the Background has nothing to load"); +} diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index d876c18278..fad5d0d06a 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -14,7 +14,7 @@ //! the page-only `blendIfChannel`; `channelNames`: the document's colour channels. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; -use photocraft_doc::effects::{FxPaint, GlowSource, StrokePosition}; +use photocraft_doc::effects::{FxPaint, GlowSource, GlowTechnique, StrokePosition}; use photocraft_doc::{Effect, Layer}; use serde_json::{Map, Value, json}; @@ -89,6 +89,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { const TECHNIQUE: &[(&str, &str)] = &[("smooth", "Smooth"), ("chiselHard", "Chisel Hard"), ("chiselSoft", "Chisel Soft")]; const DIR: &[(&str, &str)] = &[("up", "Up"), ("down", "Down")]; const SRC: &[(&str, &str)] = &[("edge", "Edge"), ("center", "Center")]; + const GLOW_TECHNIQUE: &[(&str, &str)] = &[("softer", "Softer"), ("precise", "Precise")]; const CONTOUR: P = P::Contour(Some("antiAlias")); match kind { "dropShadow" => &[ @@ -116,24 +117,28 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("contour", "Contour", CONTOUR), ("noise", "Noise", P::Slider(0.0, 100.0, "%")), ], + // Photoshop's order (#6698): Technique before the spread or choke, Range after the contour. "outerGlow" => &[ ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), + ("technique", "Technique", P::Choice(GLOW_TECHNIQUE)), ("spread", "Spread", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), - ("range", "Range", P::Slider(1.0, 100.0, "%")), ("contour", "Contour", CONTOUR), + ("range", "Range", P::Slider(1.0, 100.0, "%")), ("noise", "Noise", P::Slider(0.0, 100.0, "%")), ], "innerGlow" => &[ ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), ("color", "Color", P::Color), + ("technique", "Technique", P::Choice(GLOW_TECHNIQUE)), ("source", "Source", P::Choice(SRC)), ("choke", "Choke", P::Slider(0.0, 100.0, "%")), ("size", "Size", P::Slider(0.0, 250.0, "px")), ("contour", "Contour", CONTOUR), + ("range", "Range", P::Slider(1.0, 100.0, "%")), ("noise", "Noise", P::Slider(0.0, 100.0, "%")), ], "stroke" => &[ @@ -141,6 +146,7 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("position", "Position", P::Choice(POS)), ("blend", "Blend Mode", P::Blend), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%")), + ("overprint", "Overprint", P::Check), ("color", "Color", P::Color), ], "colorOverlay" => &[("blend", "Blend Mode", P::Blend), ("color", "Color", P::Color), ("opacity", "Opacity", P::Slider(0.0, 100.0, "%"))], @@ -172,7 +178,13 @@ fn spec(kind: &str) -> &'static [(&'static str, &'static str, P)] { ("angle", "Angle", P::Angle), ("useGlobalLight", "Use Global Light", P::Check), ("altitude", "Altitude", P::Slider(0.0, 90.0, "°")), - ("glossContour", "Gloss Contour", P::Contour(None)), + ("glossContour", "Gloss Contour", P::Contour(Some("glossAntiAlias"))), + ("highlightBlend", "Highlight Mode", P::Blend), + ("highlightColor", "Highlight Color", P::Color), + ("highlightOpacity", "Highlight Opacity", P::Slider(0.0, 100.0, "%")), + ("shadowBlend", "Shadow Mode", P::Blend), + ("shadowColor", "Shadow Color", P::Color), + ("shadowOpacity", "Shadow Opacity", P::Slider(0.0, 100.0, "%")), // Bevel & Emboss › Contour element. ("contour", "Contour", P::Check), ("contourName", "", P::Contour(Some("contourAntiAlias"))), @@ -243,6 +255,7 @@ fn values_of(e: &Effect, light: f32) -> Value { } else { drop_keys(&mut v, &["color"]); } + set(&mut v, "technique", json!(if g.technique == GlowTechnique::Precise { "precise" } else { "softer" })); set(&mut v, if matches!(e, Effect::OuterGlow(_)) { "spread" } else { "choke" }, json!((g.spread * 100.0).round())); set(&mut v, "size", json!(g.size)); set(&mut v, "range", json!((g.range * 100.0).round())); @@ -266,6 +279,7 @@ fn values_of(e: &Effect, light: f32) -> Value { _ => "outside", }), ); + set(&mut v, "overprint", json!(s.overprint)); if let FxPaint::Color(c) = &s.paint { set(&mut v, "color", json!(hex(c))); } else { @@ -282,28 +296,27 @@ fn values_of(e: &Effect, light: f32) -> Value { Effect::GradientOverlay { common, gradient: g, .. } => { set(&mut v, "blend", json!(common.blend.label())); set(&mut v, "opacity", json!((common.opacity * 100.0).round())); - // The From/To fields model a plain two-stop gradient; anything richer - // (more stops, transparency stops) is carried by the snapshot instead. - if g.stops.len() == 2 && g.opacity_stops.is_empty() { - set(&mut v, "from", json!(hex(&g.stops[0].1))); - set(&mut v, "to", json!(hex(&g.stops[1].1))); - set(&mut v, "reverse", json!(g.reverse)); - set( - &mut v, - "style", - json!(match g.style { - photocraft_doc::effects::GradientStyle::Radial => "radial", - photocraft_doc::effects::GradientStyle::Angle => "angle", - photocraft_doc::effects::GradientStyle::Reflected => "reflected", - photocraft_doc::effects::GradientStyle::Diamond => "diamond", - photocraft_doc::effects::GradientStyle::Linear => "linear", - }), - ); - set(&mut v, "angle", json!(g.angle)); - set(&mut v, "scale", json!((g.scale * 100.0).round())); - } else { - drop_keys(&mut v, &["from", "to", "reverse", "style", "angle", "scale"]); + // Endpoint colours and geometry always load into the dialog. A baked curve + // (classic interpolation, PSD import) keeps its interior stops in `fx`; + // `overlay_gradient` only retints the ends unless `method` is present. + if let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) { + set(&mut v, "from", json!(hex(&first.1))); + set(&mut v, "to", json!(hex(&last.1))); } + set(&mut v, "reverse", json!(g.reverse)); + set( + &mut v, + "style", + json!(match g.style { + photocraft_doc::effects::GradientStyle::Radial => "radial", + photocraft_doc::effects::GradientStyle::Angle => "angle", + photocraft_doc::effects::GradientStyle::Reflected => "reflected", + photocraft_doc::effects::GradientStyle::Diamond => "diamond", + photocraft_doc::effects::GradientStyle::Linear => "linear", + }), + ); + set(&mut v, "angle", json!(g.angle)); + set(&mut v, "scale", json!((g.scale * 100.0).round())); } Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => { set(&mut v, "blend", json!(common.blend.label())); @@ -355,6 +368,13 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "altitude", json!(b.altitude)); set(&mut v, "direction", json!(if b.up { "up" } else { "down" })); set(&mut v, "glossContour", photocraft_engine::layer_style::contour_param(&b.gloss_contour)); + set(&mut v, "glossAntiAlias", json!(b.gloss_anti_alias)); + set(&mut v, "highlightBlend", json!(b.highlight.blend.label())); + set(&mut v, "highlightColor", json!(hex(&b.highlight_color))); + set(&mut v, "highlightOpacity", json!((b.highlight.opacity * 100.0).round())); + set(&mut v, "shadowBlend", json!(b.shadow.blend.label())); + set(&mut v, "shadowColor", json!(hex(&b.shadow_color))); + set(&mut v, "shadowOpacity", json!((b.shadow.opacity * 100.0).round())); set(&mut v, "contour", json!(b.contour.is_some())); if let Some(c) = &b.contour { set(&mut v, "contourName", photocraft_engine::layer_style::contour_param(&c.contour)); @@ -636,7 +656,21 @@ fn apply(f: &Map, mut run: impl FnMut(&str, Value) -> Result json!({"kind": kind, "fx": fx, "params": params}), + Some(fx) => { + if kind == "bevelEmboss" + && let Ok(original) = serde_json::from_value::(fx.clone()) + && let Some(edits) = params.as_object_mut() + { + let shown = values_of(&original, initial_light_angle.unwrap_or(120.0) as f32); + // Unedited display values must not quantize imported shading in the snapshot. + for key in ["highlightBlend", "highlightColor", "highlightOpacity", "shadowBlend", "shadowColor", "shadowOpacity"] { + if edits.get(key) == shown.get(key) { + edits.remove(key); + } + } + } + json!({"kind": kind, "fx": fx, "params": params}) + } None => { // A new instance: the page shows the factory defaults merged with // the edits, and the engine builds from the full set. @@ -1044,7 +1078,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map contour::row(ui, f, &selected, key, label, aa, &mut disp), + P::Contour(aa) => contour::row(app, ui, f, &selected, key, label, aa, &mut disp), P::Check => { let mut b = disp.get(key).and_then(Value::as_bool).unwrap_or(false); if widgets::checkbox(ui, &mut b, label).changed() { @@ -1614,6 +1648,26 @@ mod tests { assert!(!items[0].enabled()); } + #[test] + fn baked_gradient_overlay_keeps_endpoint_colours_on_reopen() { + let mut s = session(); + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#ff0000", "to": "#0000ff", "method": "classic"})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let layer = st.doc.layer(id).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &layer.effects.items[0] else { panic!() }; + assert!(gradient.stops.len() > 2, "classic bakes intermediate stops"); + let f = initial_fields(layer, Some("gradientOverlay"), st.doc.global_light.angle); + let params = &effects_of(&f)[0]["params"]; + assert_eq!(params["from"], json!("#ff0000")); + assert_eq!(params["to"], json!("#0000ff")); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0] else { panic!() }; + assert!(gradient.stops.len() > 2); + assert_eq!(gradient.stops.first().unwrap().1, photocraft_color::Color::rgb(1.0, 0.0, 0.0)); + assert_eq!(gradient.stops.last().unwrap().1, photocraft_color::Color::rgb(0.0, 0.0, 1.0)); + } + #[test] fn gradient_strokes_survive_the_dialog() { let mut s = session(); @@ -1634,6 +1688,40 @@ mod tests { ); } + #[test] + fn bevel_gloss_anti_alias_survives_the_dialog() { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + assert_eq!(effects_of(&f)[0]["params"]["glossAntiAlias"], json!(true)); + // Editing another setting keeps the gloss contour's Anti-aliased. + set_param(&mut f, "fx1", "size", json!(12)); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let st = s.active().unwrap(); + let items = &st.doc.layer(id).unwrap().effects.items; + let Some(photocraft_doc::Effect::BevelEmboss(b)) = items.first() else { panic!("{items:?}") }; + assert_eq!(b.size, 12.0); + assert!(b.gloss_anti_alias); + } + + #[test] + fn stroke_overprint_survives_the_dialog() { + let mut s = session(); + s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#000000", "overprint": true})).unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + assert_eq!(effects_of(&f)[0]["params"]["overprint"], json!(true)); + // Editing another setting keeps Overprint. + set_param(&mut f, "fx1", "size", json!(5)); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let strokes = strokes_of(&s); + assert_eq!(strokes[0].size, 5.0); + assert!(strokes[0].overprint); + } + #[test] fn preview_applies_the_style_without_touching_the_document() { let s = session(); @@ -1886,6 +1974,48 @@ mod tests { assert_eq!(source(&s), Some(GlowSource::Center), "OK keeps the source"); } + /// #6698: Inner Glow lacked Photoshop's Technique and Range, Outer Glow its Technique. They + /// show in Photoshop's order, the dialog loads them, and OK keeps them. + #[test] + fn glows_show_and_keep_technique_and_range() { + let keys = |kind: &str| spec(kind).iter().map(|(k, _, _)| *k).collect::>(); + assert_eq!(keys("innerGlow"), ["blend", "opacity", "color", "technique", "source", "choke", "size", "contour", "range", "noise"]); + assert_eq!(keys("outerGlow"), ["blend", "opacity", "color", "technique", "spread", "size", "contour", "range", "noise"]); + let mut s = session(); + s.execute("layer.layerStyle.innerGlow", json!({"technique": "precise", "range": 30})).unwrap(); + s.execute("layer.layerStyle.outerGlow", json!({"technique": "precise"})).unwrap(); + let glows = |s: &photocraft_engine::Session| { + let d = s.active().unwrap(); + let id = d.active_layer.unwrap(); + d.doc + .layer(id) + .unwrap() + .effects + .items + .iter() + .filter_map(|e| match e { + Effect::InnerGlow(g) | Effect::OuterGlow(g) => Some((kind_of(e), g.technique, (g.range * 100.0).round())), + _ => None, + }) + .collect::>() + }; + let expected = [("outerGlow", GlowTechnique::Precise, 50.0), ("innerGlow", GlowTechnique::Precise, 30.0)]; + let mut have = glows(&s); + have.sort_by_key(|g| g.0); + let mut want = expected.to_vec(); + want.sort_by_key(|g| g.0); + assert_eq!(have, want); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let inner = effects_of(&f).iter().find(|e| e["kind"] == json!("innerGlow")).unwrap().clone(); + assert_eq!((&inner["params"]["technique"], &inner["params"]["range"]), (&json!("precise"), &json!(30.0)), "the dialog shows them"); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let mut have = glows(&s); + have.sort_by_key(|g| g.0); + assert_eq!(have, want, "OK keeps them"); + } + #[test] fn drop_shadow_angle_reaches_the_effect() { // #350: the Angle slider was dead — the dialog never sent `useGlobalLight`, so the engine @@ -2067,6 +2197,73 @@ mod tests { } } + #[test] + fn unedited_bevel_shading_keeps_imported_precision() { + for size in [None, Some(12.0)] { + let mut s = session(); + s.execute("layer.layerStyle.bevelEmboss", json!({})).unwrap(); + s.edit("imported shading", |doc, active| { + let Effect::BevelEmboss(b) = &mut doc.layer_mut(active.unwrap()).unwrap().effects.items[0] else { panic!("bevel") }; + b.highlight_color = photocraft_color::Color::rgb(0.12345, 0.45678, 0.78901); + b.shadow_color = photocraft_color::Color::rgb(0.98765, 0.54321, 0.13579); + b.highlight.opacity = 0.3525; + b.shadow.opacity = 0.6175; + Ok(()) + }) + .unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let original = st.doc.layer(id).unwrap().effects.items[0].clone(); + let mut fields = initial_fields(st.doc.layer(id).unwrap(), Some("bevelEmboss"), st.doc.global_light.angle); + if let Some(size) = size { + set_param(&mut fields, "fx1", "size", json!(size)); + } + apply(&fields, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let Effect::BevelEmboss(before) = original else { panic!("bevel") }; + let Effect::BevelEmboss(after) = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0] else { panic!("bevel") }; + assert_eq!(after.highlight, before.highlight); + assert_eq!(after.shadow, before.shadow); + assert_eq!(after.highlight_color, before.highlight_color); + assert_eq!(after.shadow_color, before.shadow_color); + if let Some(size) = size { + assert_eq!(after.size, size, "the unrelated edit still applies"); + } + } + } + + #[test] + fn bevel_shading_round_trips_through_dialog_preview_and_confirm() { + let mut s = session(); + s.execute( + "layer.layerStyle.bevelEmboss", + json!({ + "highlightBlend": "Normal", "highlightColor": "#e08020", "highlightOpacity": 25, + "shadowBlend": "Multiply", "shadowColor": "#204080", "shadowOpacity": 40 + }), + ) + .unwrap(); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let mut fields = initial_fields(st.doc.layer(id).unwrap(), Some("bevelEmboss"), st.doc.global_light.angle); + let values = &entry(&fields, "fx1").unwrap()["params"]; + assert_eq!(values["highlightColor"], "#e08020"); + assert_eq!(values["highlightOpacity"], 25.0); + assert_eq!(values["shadowColor"], "#204080"); + assert_eq!(values["shadowOpacity"], 40.0); + + set_param(&mut fields, "fx1", "highlightBlend", json!("Screen")); + set_param(&mut fields, "fx1", "highlightOpacity", json!(80.0)); + set_param(&mut fields, "fx1", "shadowColor", json!("#ff0000")); + assert!(preview_document(&st.doc, &s.patterns, &fields).is_ok()); + apply(&fields, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + let effect = &s.active().unwrap().doc.layer(id).unwrap().effects.items[0]; + let Effect::BevelEmboss(bevel) = effect else { panic!("expected bevel") }; + assert_eq!(bevel.highlight.blend, photocraft_doc::BlendMode::Screen); + assert!((bevel.highlight.opacity - 0.8).abs() < 0.001); + assert_eq!(hex(&bevel.shadow_color), "#ff0000"); + assert_eq!(hex(&bevel.highlight_color), "#e08020"); + } + #[test] fn preview_off_shows_the_original_until_reenabled() { let mut s = session(); diff --git a/crates/ui-egui/src/layer_style/contour.rs b/crates/ui-egui/src/layer_style/contour.rs index 2f43c676c0..295cf9f34c 100644 --- a/crates/ui-egui/src/layer_style/contour.rs +++ b/crates/ui-egui/src/layer_style/contour.rs @@ -70,13 +70,36 @@ fn picker_target(effect: &str, key: &str) -> Value { /// Picks a preset for `effect`'s `key` (the picker's click). pub(super) fn pick(f: &mut Map, effect: &str, key: &str, name: &str) { - set_param(f, effect, key, json!(name)); + pick_param(f, effect, key, json!(name)); +} + +/// Picks a contour param (a preset name, or a user contour's curve object). +pub(super) fn pick_param(f: &mut Map, effect: &str, key: &str, param: Value) { + set_param(f, effect, key, param); f.remove(PICKER); } +/// What the picker's menu asked for (Photoshop's Load, Save and Replace Contours). +#[derive(Clone, Copy, PartialEq)] +enum Act { + Load, + Replace, + Save, +} + /// The Contour row of an effect page: label, thumbnail (opens the Contour Editor), the preset /// picker arrow and, when the effect has one, Anti-aliased. -pub(super) fn row(ui: &mut egui::Ui, f: &mut Map, effect: &str, key: &str, label: &str, aa_key: Option<&str>, disp: &mut Value) { +#[allow(clippy::too_many_arguments)] +pub(super) fn row( + app: &mut PhotocraftApp, + ui: &mut egui::Ui, + f: &mut Map, + effect: &str, + key: &str, + label: &str, + aa_key: Option<&str>, + disp: &mut Value, +) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { if label.is_empty() { @@ -105,7 +128,13 @@ pub(super) fn row(ui: &mut egui::Ui, f: &mut Map, effect: &str, k open = !open; } let mut chosen: Option<&str> = None; + let mut chosen_user: Option = None; + let mut delete: Option = None; + let mut act: Option = None; + let user = &app.session.presets.contours; + let parent_layer = arrow.layer_id; egui::Popup::from_response(&arrow).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + crate::press_menu::above_parent_ui(parent_layer, ui); ui.spacing_mut().item_spacing = vec2(4.0, 4.0); egui::Grid::new(("contour-presets", effect, key)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { for (i, (name, _)) in CONTOURS.iter().enumerate() { @@ -123,7 +152,55 @@ pub(super) fn row(ui: &mut egui::Ui, f: &mut Map, effect: &str, k } } }); + // The user's own contours follow the standard ones (Contour Editor › New…). + if !user.is_empty() { + ui.add_space(4.0); + egui::Grid::new(("contour-user", effect, key)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, u) in user.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click()); + draw(ui.painter(), r, u, t.text, &t); + if *u == c || resp.hovered() { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside); + } + let name = name_of(u).to_string(); + let resp = resp.on_hover_text(name.as_str()); + if resp.clicked() { + chosen_user = Some(contour_param(u)); + } + resp.context_menu(|ui| { + if ui.button(tl!("Delete Contour")).clicked() { + delete = Some(name.clone()); + ui.close(); + } + }); + if i % 4 == 3 { + ui.end_row(); + } + } + }); + } + ui.separator(); + if ui.button(tl!("Load Contours…")).clicked() { + act = Some(Act::Load); + } + if ui.button(tl!("Replace Contours…")).clicked() { + act = Some(Act::Replace); + } + if ui.add_enabled(!user.is_empty(), egui::Button::new(tl!("Save Contours…"))).clicked() { + act = Some(Act::Save); + } }); + if let Some(name) = delete { + let r = app.run("layer.layerStyle.contours.delete", json!({"name": name})); + report(app, r); + } + if let Some(act) = act { + library_action(app, act); + } + if let Some(param) = chosen_user { + disp[key] = param.clone(); + pick_param(f, effect, key, param); + } if let Some(name) = chosen { disp[key] = json!(name); pick(f, effect, key, name); @@ -259,6 +336,10 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = None; let mut load = false; let mut save = false; + let naming_id = mem.with("naming"); + let mut naming: Option = ctx.data(|d| d.get_temp(naming_id)); + let mut new_clicked = false; + let mut commit_name = false; let mut preset: Option = None; let modal = egui::Modal::new(mem.with("modal")).show(&ctx, |ui| { ui.label(RichText::new(tl!("Contour Editor")).font(crate::theme::semibold(14.0)).color(t.text)); @@ -321,6 +402,20 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map set_param(f, &effect, &key, v["contour"].clone()), + Err(e) => report(app, Err(e)), + } + } + ctx.data_mut(|d| match &naming { + Some(n) => { + d.insert_temp(naming_id, n.clone()); + } + None => { + d.remove_temp::(naming_id); + } + }); if let Some(name) = preset { set_param(f, &effect, &key, json!(name)); } if close.is_none() && modal.should_close() { close = Some(false); } - if close.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { + if close.is_none() && naming.is_none() && !commit_name && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { close = Some(true); } if let Some(keep) = close { + ctx.data_mut(|d| d.remove_temp::(naming_id)); close_editor(f, keep); return; } @@ -396,6 +513,42 @@ pub(super) fn editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Contour { + Contour::Custom { + name: name.to_string(), + points: pts.iter().map(|q| CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect(), + corners: corners.to_vec(), + } +} + +/// Load, Replace or Save Contours: the library lives in the session, the file is an `.shc`. +fn library_action(app: &mut PhotocraftApp, act: Act) { + let r = match act { + Act::Load | Act::Replace => app.pick_file_bytes_filtered(&["shc"], move |app, name, bytes| { + let mut p = crate::swatches_ui::source(&name, &bytes); + p["mode"] = json!(if act == Act::Replace { "replace" } else { "append" }); + let r = app.run("layer.layerStyle.contours.load", p)?; + let template = "Loaded contours from {file}"; + app.ui.status_error = false; + app.ui.status = crate::i18n::fmt(tl!(template), &[("file", &crate::file_open::display_name(&name))]); + Ok(r) + }), + Act::Save => app.run("layer.layerStyle.contours.save", json!({})).and_then(|r| { + let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?; + app.pick_save("Contours.shc", move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + let template = "Saved contours to {file}"; + app.ui.status_error = false; + app.ui.status = crate::i18n::fmt(tl!(template), &[("file", &crate::file_open::display_name(&path))]); + Ok(json!({"path": path})) + }) + }), + }; + report(app, r); +} + fn report(app: &mut PhotocraftApp, r: Result) { if let Err(e) = r && e != crate::file_dialog::CANCELLED @@ -404,3 +557,32 @@ fn report(app: &mut PhotocraftApp, r: Result) { app.ui.status = e; } } + +#[cfg(test)] +mod library_tests { + use super::*; + + #[test] + fn cancelling_the_editor_discards_the_contour_name_draft() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerStyle.dropShadow", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let mut fields = crate::layer_style::initial_fields(st.doc.layer(st.active_layer.unwrap()).unwrap(), None, 120.0); + open_editor(&mut fields, "fx1", "contour"); + let naming = egui::Id::new(("contour-editor", "fx1", "contour")).with("naming"); + ctx.data_mut(|d| d.insert_temp(naming, "Unfinished".to_string())); + ctx.run_ui(Default::default(), |ui| editor(&mut app, ui, &mut fields)).textures_delta.clear(); + let input = egui::RawInput { + events: vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.run_ui(input, |ui| editor(&mut app, ui, &mut fields)).textures_delta.clear(); + assert!(!fields.contains_key(EDITOR)); + assert!(ctx.data(|d| d.get_temp::(naming)).is_none()); + assert!(app.session.presets.contours.is_empty()); + } +} diff --git a/crates/ui-egui/src/layer_tree_ui/tests.rs b/crates/ui-egui/src/layer_tree_ui/tests.rs index 13e73353b3..0b3d4c2724 100644 --- a/crates/ui-egui/src/layer_tree_ui/tests.rs +++ b/crates/ui-egui/src/layer_tree_ui/tests.rs @@ -21,6 +21,8 @@ fn layered() -> photocraft_engine::Session { s.execute("layer.new.layer", json!({"name": "in-outer"})).unwrap(); s.execute("layer.select", json!({"layer": outer})).unwrap(); s.execute("layer.new.layer", json!({"name": "top"})).unwrap(); + // Group Layers creates collapsed groups (#5571); these tests start with every group open. + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); s } diff --git a/crates/ui-egui/src/layers_panel_ui.rs b/crates/ui-egui/src/layers_panel_ui.rs new file mode 100644 index 0000000000..5027b228b0 --- /dev/null +++ b/crates/ui-egui/src/layers_panel_ui.rs @@ -0,0 +1,478 @@ +//! Layers panel flyout and display preferences, observed from Photoshop's panel menu. +//! UI options do not edit document pixels or add history steps. +use egui::{self, Context, Ui}; +use photocraft_doc::Document; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::{LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog, LayersPanelOptions}; + +const OPTIONS_PREF: &str = "ui.layersPanelOptions"; + +/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. +pub fn persist_options(app: &mut PhotocraftApp) { + if let Ok(value) = serde_json::to_value(&app.ui.layers_panel_options) { + app.session.prefs.edit(|p| { + p.dialogs.insert(OPTIONS_PREF.into(), value); + }); + } +} + +/// Restore independently from a saved workspace. Older preferences retain the defaults. +pub fn restore_options(app: &mut PhotocraftApp) { + if let Some(opts) = app.session.prefs().dialogs.get(OPTIONS_PREF) + && let Ok(value) = serde_json::from_value(opts.clone()) + { + app.ui.layers_panel_options = value; + } +} + +/// The real menu command dispatcher handles rename's in-place editor and other shell routing. +pub fn run_menu_command(app: &mut PhotocraftApp, ui: &mut Ui, label: &str, id: &str) { + if ui.add_enabled(app.session.is_enabled(id), egui::Button::new(label)).clicked() { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + ui.close(); + } +} + +/// Thumbnail Contents as the shared preference has it: Layer Bounds is Clip Thumbnails to Layer +/// Bounds (#4281), which frames each layer's pixels instead of the canvas. +pub fn thumbnail_contents(app: &PhotocraftApp) -> LayerThumbnailContents { + if app.session.prefs().interface.clip_thumbnails_to_layer_bounds { LayerThumbnailContents::LayerBounds } else { LayerThumbnailContents::EntireDocument } +} + +/// Panel Options › OK: the panel's own options, and Thumbnail Contents into the shared preference. +fn apply_options(app: &mut PhotocraftApp, options: LayersPanelOptions) -> Result<(), String> { + let contents = options.thumbnail_contents; + app.ui.layers_panel_options = options; + persist_options(app); + if contents == thumbnail_contents(app) { + return Ok(()); + } + let clip = contents == LayerThumbnailContents::LayerBounds; + app.run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": clip})).map(|_| ()) +} + +/// Preview cache recomputes only when hovering a new target, not each pointer frame. +/// Compositor sees it as a temporary document and never commits it to history. +pub(crate) struct ReorderPreview { + pub source: photocraft_doc::DocId, + pub revision: u64, + pub signature: u64, + pub key: u64, + pub document: std::sync::Arc, +} + +/// Cache the on-canvas preview of the layer drop in `payload` (a `layer.moveTo` request), unless Preview on Canvas is off. +/// Marks the destination as hovered every frame; the preview itself is rebuilt only when the target changes. +pub(crate) fn preview_reorder(app: &mut PhotocraftApp, payload: &serde_json::Value) { + if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { + app.reorder_preview = None; + return; + } + // Mark the destination as hovered even if the cached preview already matches. + app.reorder_preview_hit = true; + let Some(st) = app.session.active() else { return }; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + payload.to_string().hash(&mut hasher); + st.doc.id.0.hash(&mut hasher); + st.revision.hash(&mut hasher); + let signature = hasher.finish(); + if app.reorder_preview.as_ref().is_some_and(|cache| cache.signature == signature) { + return; + } + let (source, revision) = (st.doc.id, st.revision); + let add_copy = app.ui.layers_panel_options.add_copy_to_copied_layers_and_groups; + let preview = photocraft_engine::layer_multi_cmds::preview_move(&st.doc, payload, add_copy); + app.reorder_preview = preview.map(|document| ReorderPreview { + source, + revision, + signature, + key: (1u64 << 60) | (signature & ((1u64 << 60) - 1)), + document: std::sync::Arc::new(document), + }); +} + +/// The cached reorder preview and its compositor cache key for document `index`, if it was built for that document's current revision. +pub(crate) fn display_reorder(app: &PhotocraftApp, index: usize) -> Option<(std::sync::Arc, u64)> { + if !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { + return None; + } + let st = app.session.documents().get(index)?; + let cached = app.reorder_preview.as_ref()?; + (cached.source == st.doc.id && cached.revision == st.revision).then(|| (cached.document.clone(), cached.key)) +} + +/// Photoshop shows the same flower at three resolutions. This example is original, +/// vector-painted artwork, not an extracted Adobe asset, and it is independent of +/// whichever layer happens to be selected (including adjustment layers). +fn paint_flower_example(ui: &Ui, rect: egui::Rect) { + use egui::{Color32, Rect as UiRect, Shape, Stroke, pos2, vec2}; + let painter = ui.painter().with_clip_rect(rect); + let side = rect.width(); + let point = |x: f32, y: f32| pos2(rect.min.x + x * side, rect.min.y + y * side); + // A monochrome miniature landscape: light sky, shaded hills and a dark meadow. + for n in 0..22 { + let y0 = n as f32 / 22.0; + let y1 = (n + 1) as f32 / 22.0; + let f = n as f32 / 22.0; + painter.rect_filled(UiRect::from_min_max(point(0.0, y0), point(1.0, y1)), 0.0, Color32::from_gray((119.0 + f * 30.0) as u8)); + } + painter.add(Shape::convex_polygon( + vec![point(0.0, 0.60), point(0.22, 0.36), point(0.46, 0.56), point(0.72, 0.33), point(1.0, 0.62)], + Color32::from_gray(80), + Stroke::NONE, + )); + painter.add(Shape::convex_polygon( + vec![point(0.0, 0.76), point(0.32, 0.60), point(0.57, 0.69), point(1.0, 0.51), point(1.0, 1.0), point(0.0, 1.0)], + Color32::from_gray(64), + Stroke::NONE, + )); + for n in 0..16 { + let x = ((n * 43 + 13) % 97) as f32 / 96.0; + let y = 0.78 + ((n * 11 % 19) as f32) / 100.0; + painter.line_segment([point(x, 1.0), point((x + 0.02).min(1.0), y)], Stroke::new(side * 0.01, Color32::from_gray(104))); + } + // A white daisy with layered petals and a dark central disc, in the same + // composition at all three sizes, so the controls genuinely preview scale. + let center = point(0.49, 0.57); + let unit = side * 0.34; + for n in 0..15 { + let theta = (n as f32 + 0.5) * std::f32::consts::TAU / 15.0; + let direction = vec2(theta.cos(), theta.sin()); + let normal = vec2(-theta.sin(), theta.cos()); + let mut outline = Vec::with_capacity(20); + for i in 0..=9 { + let t = i as f32 / 9.0; + let base = center + direction * unit * (0.22 + 0.83 * t); + outline.push(base + normal * (unit * 0.22 * (std::f32::consts::PI * t).sin())); + } + for i in (0..=9).rev() { + let t = i as f32 / 9.0; + let base = center + direction * unit * (0.22 + 0.83 * t); + outline.push(base - normal * (unit * 0.22 * (std::f32::consts::PI * t).sin())); + } + painter.add(Shape::convex_polygon( + outline, + if n % 3 == 0 { Color32::from_gray(213) } else { Color32::from_gray(238) }, + Stroke::new(0.3, Color32::from_gray(155)), + )); + } + painter.circle_filled(center, unit * 0.27, Color32::from_gray(33)); + painter.circle_filled(center - vec2(unit * 0.065, unit * 0.055), unit * 0.16, Color32::from_gray(55)); + for n in 0..16 { + let theta = (n as f32) * std::f32::consts::TAU / 16.0; + let r = unit * if n % 2 == 0 { 0.16 } else { 0.21 }; + painter.circle_filled(center + vec2(theta.cos(), theta.sin()) * r, (side * 0.008).max(0.3), Color32::from_gray(119)); + } + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::from_gray(92)), egui::StrokeKind::Inside); +} + +/// Returns both the actual thumbnail width and the height allocated by the Layers +/// row. Used by painting and by tests to keep the preference functional. +pub(crate) fn thumbnail_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32) { + let thumb: f32 = match size { + LayerThumbnailSize::None => 0.0, + LayerThumbnailSize::Small => 18.0, + LayerThumbnailSize::Medium => { + if pro { + 24.0 + } else { + 34.0 + } + } + LayerThumbnailSize::Large => 48.0, + }; + let row = if thumb == 0.0 { 23.0 } else { (thumb + 8.0).max(if pro { 32.0 } else { 40.0 }) }; + (thumb, row) +} + +/// Content column of the Photoshop-style dialog, with fixed-size radio/preview +/// slots rather than text labels that change the position of each thumbnail. +fn options_body(ui: &mut Ui, draft: &mut LayersPanelOptions) { + ui.group(|ui| { + ui.set_min_width(308.0); + ui.label(egui::RichText::new(tl!("Thumbnail Size")).strong()); + ui.add_space(7.0); + // The original Photoshop dialog has None with a label, then progressively + // larger previews, with the radio controls aligned vertically. + ui.horizontal(|ui| { + let r = ui.radio_value(&mut draft.thumbnail_size, LayerThumbnailSize::None, tl!("None")); + r.on_hover_text(tl!("None")); + }); + ui.add_space(3.0); + for (size, side, name) in [ + (LayerThumbnailSize::Small, 38.0, tl!("Small")), + (LayerThumbnailSize::Medium, 68.0, tl!("Medium")), + (LayerThumbnailSize::Large, 96.0, tl!("Large")), + ] { + ui.horizontal(|ui| { + // Empty radio captions are intentional: the sample itself conveys + // size. The accessible/hover label still names the setting. + let radio = ui.radio_value(&mut draft.thumbnail_size, size, ""); + radio.on_hover_text(name); + ui.add_space(8.0); + let (rect, response) = ui.allocate_exact_size(egui::vec2(side, side), egui::Sense::click()); + paint_flower_example(ui, rect); + if response.clicked() { + draft.thumbnail_size = size; + } + response.on_hover_text(name); + }); + ui.add_space(5.0); + } + }); + ui.add_space(9.0); + ui.group(|ui| { + ui.set_min_width(308.0); + ui.label(egui::RichText::new(tl!("Thumbnail Contents")).strong()); + ui.add_space(10.0); + ui.radio_value(&mut draft.thumbnail_contents, LayerThumbnailContents::LayerBounds, tl!("Layer Bounds")); + ui.add_space(4.0); + ui.radio_value(&mut draft.thumbnail_contents, LayerThumbnailContents::EntireDocument, tl!("Entire Document")); + ui.add_space(4.0); + }); + ui.add_space(9.0); + ui.checkbox(&mut draft.use_default_masks_on_fill_layers, tl!("Use Default Masks on Fill Layers")); + ui.add_space(3.0); + ui.checkbox(&mut draft.expand_new_effects, tl!("Expand New Effects")); + ui.add_space(3.0); + ui.checkbox(&mut draft.add_copy_to_copied_layers_and_groups, tl!("Add “copy” to Copied Layers and Groups")); + ui.add_space(3.0); + ui.checkbox(&mut draft.preview_on_canvas_when_reordering_layers, tl!("Preview on canvas when reordering layers")); + ui.add_space(3.0); + ui.checkbox(&mut draft.show_layer_mask_badges, tl!("Show layer mask badges")); +} + +/// Show the pending Layers panel dialog (Panel Options, New Layer, New Group, Lock Layers) as a modal and apply it on OK. +pub fn show_dialog(app: &mut PhotocraftApp, ctx: &Context) { + let Some(mut dialog) = app.ui.layers_panel_dialog.take() else { return }; + let mut confirmed = false; + let mut cancelled = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let title = match &dialog { + LayersPanelDialog::PanelOptions(_) => tl!("Layers Panel Options"), + LayersPanelDialog::NewLayer(_) => tl!("New Layer"), + LayersPanelDialog::NewGroup(_) => tl!("New Group"), + LayersPanelDialog::LockLayers { .. } => tl!("Lock Layers"), + }; + egui::Modal::new(egui::Id::new("layers-panel-options-modal")).show(ctx, |ui| { + ui.set_width(if matches!(dialog, LayersPanelDialog::PanelOptions(_)) { 480.0 } else { 380.0 }); + ui.horizontal(|ui| { + ui.heading(title); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("×").on_hover_text(tl!("Cancel")).clicked() { + cancelled = true; + } + }); + }); + ui.separator(); + match &mut dialog { + LayersPanelDialog::PanelOptions(opts) => { + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(345.0); + ui.vertical(|ui| options_body(ui, opts)); + }); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.set_min_width(74.0); + if ui.add_sized([74.0, 29.0], egui::Button::new(tl!("OK"))).clicked() { + confirmed = true; + } + ui.add_space(6.0); + if ui.add_sized([74.0, 29.0], egui::Button::new(tl!("Cancel"))).clicked() { + cancelled = true; + } + }); + }); + } + LayersPanelDialog::NewLayer(name) | LayersPanelDialog::NewGroup(name) => { + ui.label(tl!("Name:")); + ui.text_edit_singleline(name); + } + LayersPanelDialog::LockLayers { transparency, pixels, position, artboard, all } => { + ui.checkbox(transparency, tl!("Transparency")); + ui.checkbox(pixels, tl!("Image")); + ui.checkbox(position, tl!("Position")); + ui.checkbox(artboard, tl!("Prevent Auto-Nesting")); + ui.checkbox(all, tl!("All")); + } + } + if !matches!(dialog, LayersPanelDialog::PanelOptions(_)) { + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button(tl!("OK")).clicked() { + confirmed = true; + } + if ui.button(tl!("Cancel")).clicked() { + cancelled = true; + } + }); + } + }); + if confirmed && !cancelled { + let r = match dialog { + LayersPanelDialog::PanelOptions(options) => apply_options(app, options), + LayersPanelDialog::NewLayer(name) => { + app.run("layer.new.layer", if name.trim().is_empty() { json!({}) } else { json!({"name": name.trim()}) }).map(|_| ()) + } + LayersPanelDialog::NewGroup(name) => { + app.run("layer.new.group", if name.trim().is_empty() { json!({}) } else { json!({"name": name.trim()}) }).map(|_| ()) + } + LayersPanelDialog::LockLayers { transparency, pixels, position, artboard, all } => app + .run("layer.lockLayers", json!({"transparency": transparency, "pixels": pixels, "position": position, "artboard": artboard, "all": all})) + .map(|_| ()), + }; + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } + } else if !cancelled { + app.ui.layers_panel_dialog = Some(dialog); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh install shows Medium thumbnails of the whole document, like Photoshop's Panel Options defaults. + #[test] + fn default_panel_options_match_medium_document_previews() { + let options = LayersPanelOptions::default(); + assert_eq!(options.thumbnail_size, LayerThumbnailSize::Medium); + assert_eq!(options.thumbnail_contents, LayerThumbnailContents::EntireDocument); + assert!(options.show_filters); + assert!(options.use_default_masks_on_fill_layers); + assert!(options.expand_new_effects); + assert!(options.add_copy_to_copied_layers_and_groups); + assert!(options.preview_on_canvas_when_reordering_layers); + assert!(!options.show_layer_mask_badges); + } + + /// Thumbnail Contents is the same setting as the panel menu's Clip Thumbnails to Layer Bounds + /// (#4281): the dialog shows it and OK sets it, so the two never disagree. + #[test] + fn thumbnail_contents_is_clip_thumbnails_to_layer_bounds() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + assert_eq!(thumbnail_contents(&app), LayerThumbnailContents::EntireDocument); + let mut options = app.ui.layers_panel_options.clone(); + options.thumbnail_contents = LayerThumbnailContents::LayerBounds; + apply_options(&mut app, options.clone()).unwrap(); + assert!(app.session.prefs().interface.clip_thumbnails_to_layer_bounds); + assert_eq!(thumbnail_contents(&app), LayerThumbnailContents::LayerBounds); + assert!(app.session.prefs().dialogs[OPTIONS_PREF].get("thumbnailContents").is_none(), "saved once, in the shared preference"); + options.thumbnail_contents = LayerThumbnailContents::EntireDocument; + apply_options(&mut app, options).unwrap(); + assert!(!app.session.prefs().interface.clip_thumbnails_to_layer_bounds); + } + + /// The options survive a UiState round trip; an open dialog is view state and is never serialized. + #[test] + fn options_round_trip_without_a_pending_dialog() { + let mut ui = crate::state::UiState::default(); + ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large; + ui.layers_panel_options.show_filters = false; + ui.layers_panel_dialog = Some(LayersPanelDialog::NewGroup("test".into())); + let json = serde_json::to_value(&ui).unwrap(); + assert!(json.get("layers_panel_dialog").is_none()); + let restored: crate::state::UiState = serde_json::from_value(json).unwrap(); + assert_eq!(restored.layers_panel_options, ui.layers_panel_options); + assert!(restored.layers_panel_dialog.is_none()); + } + + /// With the option off a new fill layer has no mask and the mask is not targeted; with it on the new layer's mask is targeted. + #[test] + fn disabling_default_fill_masks_never_targets_an_absent_mask() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 32, "height": 24})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = false; + persist_options(&mut app); + app.run("layer.newFillLayer.solidColor", json!({"color": "#778899"})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_none()); + assert!(!app.ui.mask_target && !app.ui.vector_mask_target); + app.ui.layers_panel_options.use_default_masks_on_fill_layers = true; + persist_options(&mut app); + app.run("layer.newFillLayer.gradient", json!({})).unwrap(); + let layer = app.session.active().and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))).unwrap(); + assert!(layer.mask.is_some()); + assert!(app.ui.mask_target); + } + + /// Every option, including the mask badges, goes to the preferences and comes back from them. + #[test] + fn prefs_options_save_and_restore_all_switches() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let saved = LayersPanelOptions { + thumbnail_size: LayerThumbnailSize::Large, + thumbnail_contents: LayerThumbnailContents::EntireDocument, + show_filters: false, + use_default_masks_on_fill_layers: false, + expand_new_effects: false, + add_copy_to_copied_layers_and_groups: false, + preview_on_canvas_when_reordering_layers: false, + show_layer_mask_badges: true, + }; + app.ui.layers_panel_options = saved.clone(); + persist_options(&mut app); + assert_eq!(app.session.prefs().dialogs[OPTIONS_PREF]["showLayerMaskBadges"], true); + app.ui.layers_panel_options = LayersPanelOptions::default(); + restore_options(&mut app); + assert_eq!(app.ui.layers_panel_options, saved); + } + + /// The flyout's Flatten Image runs the real `layer.flattenImage` command, and Undo brings the layers back. + #[test] + fn flyout_flatten_routes_to_real_engine_and_undo_restores_layers() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 18, "height": 12})).unwrap(); + session.execute("layer.new.layer", json!({"name": "Red pixels"})).unwrap(); + session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let count = app.session.active().unwrap().doc.layers.len(); + assert!(count >= 2); + // The flyout uses menus::invoke with this exact command id. + crate::menus::invoke(&mut app, &egui::Context::default(), "layer.flattenImage", json!({})).unwrap(); + let flattened = app.session.active().unwrap(); + assert_eq!(flattened.doc.layers.len(), 1); + assert_eq!(flattened.doc.layers[0].name, "Background"); + assert!(flattened.doc.layers[0].surface().is_some()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), count); + } + + /// Thumbnail and row sizes grow from None to Large in both themes, with the rows at least as tall as the thumbnails. + #[test] + fn geometry_matches_four_real_panel_sizes() { + for pro in [false, true] { + let mut previous = -1.0; + for size in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { + let (thumb, row) = thumbnail_geometry(size, pro); + assert!(thumb > previous && row >= thumb, "{size:?}: {thumb}/{row}"); + previous = thumb; + } + } + assert_eq!(thumbnail_geometry(LayerThumbnailSize::None, true), (0.0, 23.0)); + assert_eq!(thumbnail_geometry(LayerThumbnailSize::Large, true), (48.0, 56.0)); + } + + /// Preferences saved before an option existed still load, with the default for the missing ones. + #[test] + fn panel_options_missing_fields_deserialize_with_defaults() { + let options: LayersPanelOptions = serde_json::from_value(json!({})).unwrap(); + assert_eq!(options, LayersPanelOptions::default()); + let saved = serde_json::to_value(&options).unwrap(); + assert_eq!(saved["thumbnailSize"], "medium"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8bf2be90d7..274380ba96 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,13 +19,16 @@ pub mod adjust_dialog; pub mod adjust_editors; pub mod adjust_preview; pub mod adjust_ui; +mod align_bar; mod alt_grab; pub mod analysis_ui; pub mod anchor_tools; +mod ants; pub mod artboard_tool; pub mod artboard_ui; pub(crate) mod background_redraw; pub mod bidi_text; +pub(crate) mod bitmap_dialog; mod blend_keys; pub(crate) mod blend_preview; pub mod blur_pins; @@ -58,8 +61,10 @@ pub mod cjk_fonts; pub mod clip_line_ui; pub mod color_picker_ui; pub mod color_range_ui; +pub mod color_table_ui; pub mod comps_ui; pub mod control; +mod control_click; pub mod corner_radius; pub mod credits; pub mod crop_mode; @@ -89,6 +94,7 @@ pub mod eraser_ui; pub mod export_dialog; pub mod eyedropper_ui; pub mod field_tab; +pub mod field_wheel; pub mod file_dialog; pub mod file_open; pub mod file_ui; @@ -106,11 +112,13 @@ pub mod gpu_status; pub mod gradient_ui; pub mod guide_layout_ui; pub mod guide_select; +pub mod history_menu_ui; pub mod hold_keys; pub mod i18n; mod icon_data; pub mod icons; pub mod jobs_ui; +pub mod jpeg_options_ui; pub mod kys_import; pub mod lasso_ui; pub(crate) mod last_brush; @@ -124,6 +132,7 @@ pub mod layer_row_ui; pub mod layer_style; mod layer_transfer; pub mod layer_tree_ui; +pub mod layers_panel_ui; mod layout_keys; pub mod links; pub mod liquify_ui; @@ -137,6 +146,7 @@ pub mod menu_nav; pub mod menus; pub mod monitor_status; pub mod move_box; +pub mod move_distance; pub mod move_hover; pub mod move_lock; pub mod move_mods; @@ -152,6 +162,7 @@ pub mod object_finder; mod opacity_keys; pub mod open_prompt; pub mod outline; +mod page_keys; pub mod paint_mouse; pub mod palette; mod panel_docking; @@ -175,6 +186,7 @@ pub mod proxy; pub mod puppet_ui; pub mod quick_pick; pub mod rasterize_prompt; +pub mod replace_prompt; pub mod retouch_ui; mod rgb_histogram; pub mod rotate_view; @@ -192,6 +204,7 @@ pub mod shortcuts; mod single_line_drag; mod sizing; pub mod slice_ui; +pub mod smart_filter_dialog; pub mod smart_ui; pub mod snap_ui; pub(crate) mod solid_fill_ui; @@ -208,6 +221,7 @@ pub mod swatches_ui; pub mod symmetry_ui; mod tab_strip; pub mod targa_options_ui; +pub mod texel_guard; pub mod text_field_menu; pub mod theme; pub mod tiff_options_ui; @@ -221,6 +235,7 @@ pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; mod tool_shortcuts; +pub mod touch_twins; pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; @@ -238,8 +253,10 @@ pub mod wide_angle_ui; pub mod widgets; pub mod work_area; pub mod workspace_ui; +pub mod zoom_hold; pub mod zoom_levels; pub mod zoom_tool; +pub mod zoom_window_fit; use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; @@ -253,13 +270,72 @@ pub use file_dialog::{FileDialogAnswer, FileDialogFn, FileDialogReply, FileDialo pub use file_open::OsEvent; pub use state::{Tool, UiState}; +/// The error a save gets while another save waits on its format prompt. +pub(crate) const SAVE_OPTIONS_OPEN: &str = "Answer the TIFF, Targa, DDS or JPEG Options before starting another save"; + +/// The format answers a save takes instead of asking: the Targa bits per pixel (`"tgaBits"`), +/// the DDS options (`"ddsCompression"`, `"ddsMipmaps"`) and JPEG Options (`"quality"` 0–12, +/// `"jpegFormat"`). `None` asks in Save As and takes the default in automation saves, which never +/// prompt. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FormatChoices { + pub tga_bits: Option, + pub dds: Option, + pub jpeg: Option, +} + +impl FormatChoices { + /// The answers in command or control call `cmd`'s params; a bad value is an error. + pub fn from_params(params: &Value, cmd: &str) -> Result { + Ok(FormatChoices { + tga_bits: targa_options_ui::bits_param(params, cmd)?, + dds: dds_options_ui::options_param(params, cmd)?, + jpeg: jpeg_options_ui::choice_param(params, cmd)?, + }) + } + + /// The answers in a recorded save step, read as leniently as the engine command that may have + /// recorded it (see [`jpeg_options_ui::recorded_choice`]). + pub(crate) fn from_step(params: &Value, cmd: &str) -> Result { + Ok(FormatChoices { + tga_bits: targa_options_ui::bits_param(params, cmd)?, + dds: dds_options_ui::options_param(params, cmd)?, + jpeg: jpeg_options_ui::recorded_choice(params, cmd)?, + }) + } + + /// The export settings with these answers applied. + pub fn apply(self, settings: ExportSettings) -> ExportSettings { + let settings = ExportSettings { tga_bits: self.tga_bits.or(settings.tga_bits), dds: self.dds.unwrap_or(settings.dds), ..settings }; + match self.jpeg { + Some(j) => j.apply(settings), + None => settings, + } + } + + /// Adds the answers to a recorded save step. + pub(crate) fn record(self, step: &mut Value) { + if let Some(bits) = self.tga_bits { + step["tgaBits"] = bits.bits().into(); + } + if let Some(dds) = self.dds { + dds_options_ui::record(dds, step); + } + if let Some(j) = self.jpeg { + j.record(step); + } + } +} + /// Decode a file: (document, warnings about anything approximated or dropped). -pub type ImportFn = Box Result<(Document, Vec), String>>; +pub type ImportFn = Box Result<(Document, Vec), String>>; /// Encoder settings chosen in Export As (the file format comes from the name's extension). #[derive(Clone, Debug, PartialEq)] pub struct ExportSettings { /// JPEG quality 1–100 (None = codec default). pub jpeg_quality: Option, + /// JPEG Options › Format Options: baseline, baseline optimized or progressive. + pub jpeg_format: photocraft_codecs::JpegFormat, /// WebP: lossless (VP8L) rather than lossy (VP8 at [`Self::webp_quality`]). pub webp_lossless: bool, /// Lossy WebP quality 1–100 (None = codec default). @@ -291,12 +367,15 @@ pub struct ExportSettings { /// The path of the document's own file, which linked smart objects that PSD/PSB embed are /// resolved against ([`photocraft_io::ExportOptions::document_path`]). pub document_path: Option, + /// Native save cache owned by the open document; exports without one remain one-shot. + pub pcraft_cache: Option, } impl Default for ExportSettings { fn default() -> Self { ExportSettings { jpeg_quality: None, + jpeg_format: photocraft_codecs::JpegFormat::Baseline, webp_lossless: true, webp_quality: None, tiff_layers: true, @@ -308,6 +387,7 @@ impl Default for ExportSettings { embed_icc: true, embed_metadata: true, document_path: None, + pcraft_cache: None, } } } @@ -320,6 +400,7 @@ impl ExportSettings { if let Some(q) = self.jpeg_quality { opts.encode.jpeg_quality = q; } + opts.encode.jpeg_format = self.jpeg_format; opts.encode.webp_lossless = self.webp_lossless; if let Some(q) = self.webp_quality { opts.encode.webp_quality = q; @@ -333,41 +414,11 @@ impl ExportSettings { opts.image_previews = self.image_previews; opts.psd_maximize_compatibility = self.psd_maximize_compatibility; opts.document_path = self.document_path.clone(); + opts.pcraft_cache = self.pcraft_cache.clone(); opts } } -/// The format answers a save takes instead of asking: the Targa bits per pixel and the DDS options -/// (`"tgaBits"`, `"ddsCompression"`, `"ddsMipmaps"`). `None` asks in Save As and takes the -/// default in automation saves, which never prompt. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct FormatChoices { - pub tga_bits: Option, - pub dds: Option, -} - -impl FormatChoices { - /// The answers in command `cmd`'s params; a bad value is an error. - pub fn from_params(params: &Value, cmd: &str) -> Result { - Ok(FormatChoices { tga_bits: targa_options_ui::bits_param(params, cmd)?, dds: dds_options_ui::options_param(params, cmd)? }) - } - - /// The export settings with these answers applied. - pub(crate) fn apply(self, settings: ExportSettings) -> ExportSettings { - ExportSettings { tga_bits: self.tga_bits, dds: self.dds.unwrap_or(settings.dds), ..settings } - } - - /// Adds the answers to a recorded save step. - fn record(self, step: &mut Value) { - if let Some(bits) = self.tga_bits { - step["tgaBits"] = bits.bits().into(); - } - if let Some(dds) = self.dds { - dds_options_ui::record(dds, step); - } - } -} - /// The document's lone layer when it is a plain raster a flat file keeps: visible, at full /// opacity, in Normal mode, with no masks, effects or blending restrictions (#2234). pub(crate) fn plain_raster(doc: &Document) -> Option<&photocraft_doc::Layer> { @@ -403,6 +454,8 @@ pub struct FileStamp { } /// The [`FileStamp`] of the file at a path, `None` when it can't be read. pub type FileStampFn = Box Option>; +/// Whether two paths name one entry in one folder on disk; false when either can't be read. +pub type SameFileFn = Box bool>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. @@ -515,6 +568,11 @@ pub struct Services { /// Size and modification time of a file (native); `None` on the web, where Save never /// writes back to a file. pub file_stamp: Option, + /// Whether two paths name one entry in one folder (native): a save dialog's answer and the + /// path the save writes to after Lowercase Extension, on a file system that ignores letter + /// case. A hard link is another entry. A save asks before replacing an entry it finds + /// different (`replace_prompt`). + pub same_file: Option, /// `write` only asks for the bytes to be kept (a browser download, which the user can /// cancel and which reports nothing back), so a save through it names the document but /// leaves its changes unsaved and the leave-page guard armed (#4962). @@ -568,6 +626,10 @@ pub struct Services { pub autosave: Option, /// None for services that report synchronous success through `autosave`. pub autosave_results: Option, + /// Finish every queued crash-recovery autosave and wait for it, with outcomes like + /// `autosave_results`. For a session that ends without asking: the window's graphics device + /// was lost (see `gpu_status::check_window`). + pub flush_autosaves: Option, pub discard_autosave: Option, pub recover: Option, pub adopt_autosave: Option, @@ -662,10 +724,16 @@ pub struct PhotocraftApp { pub(crate) move_preview: Option, /// Move tool › Show Highlight on Rollover: the last hover answer (`move_hover`). pub(crate) move_hover: Option, + /// Smart Guides distances shown with ⌘ held over the canvas: the last answer (`move_distance`). + pub(crate) move_distance: Option, /// Object Selection › Object Finder: the hover preview (`object_finder`). pub(crate) object_finder: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, + /// Layers panel drag: temporary reordered document displayed until mouse release. + pub(crate) reorder_preview: Option, + /// Whether the currently dragged layer is over a valid destination this frame. + pub(crate) reorder_preview_hit: bool, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). pub(crate) patch_preview: Option, /// Content-Aware Move drag or box shown with its result (`cam_preview`). @@ -763,10 +831,13 @@ pub struct PhotocraftApp { /// Selection outline cache: (doc, revision, segments). /// Live filter preview (proxy document with the filter applied). pub(crate) filter_preview: Option, + pub(crate) smart_filter_targets: smart_filter_dialog::Targets, #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: filter_preview_worker::Worker, /// Full CPU canvas redraws running in the background, by document (#4024). pub(crate) redraws: std::collections::HashMap, + /// The last finished background redraws, for going back to a state drawn a moment ago (#6528). + pub(crate) kept: background_redraw::KeptComposites, /// Select › Color Range dialog preview (proxy document + mask / image textures). pub(crate) color_range: Option, /// Image › Adjustments dialog preview through a temporary clipped adjustment layer. @@ -863,6 +934,19 @@ pub struct PhotocraftApp { /// GPU canvas renderer, when running on the wgpu backend (see [`Self::set_wgpu`]). Dropped /// for the rest of the session when the device is lost (see `gpu_status`). gpu: Option, + /// The device the window itself draws with, and its health flag: the GPU canvas's, or one + /// watched for the CPU canvas (see `gpu_status::check_window`). + window_device: Option<(eframe::wgpu::Device, photocraft_gpu::DeviceHealth)>, + /// That device was lost: frames draw nothing and the app closes once its work is saved (#3438). + window_lost: bool, + /// When (in `egui::InputState::time`) to try saving unsaved work for recovery again after a + /// failed attempt; the app closes only once it succeeded. + window_recovery_retry: Option, + /// The render state the GPU canvas was built on, kept after it is dropped so Retry GPU can + /// build a new one on the same device (see `gpu_status::choose_recovery`, #6577). + gpu_render_state: Option, + /// Why the GPU canvas was dropped (see `gpu_status::fall_back`). + gpu_fault: Option, /// Run once the first frames have rendered (see [`Self::on_started`]). started: Option, /// Frame and canvas-upload timings (exposed via `ui.inspect`). @@ -885,6 +969,8 @@ pub struct PhotocraftApp { pub(crate) disk_stamps: HashMap, /// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`). pub(crate) targa_options: Option, + /// A save to a JPEG parked behind the JPEG Options prompt (see `jpeg_options_ui`). + pub(crate) jpeg_options: Option, /// A save to a DDS texture parked behind the DDS Options prompt (see `dds_options_ui`). pub(crate) dds_options: Option, /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it @@ -894,6 +980,7 @@ pub struct PhotocraftApp { pub(crate) allow_close: bool, /// Pen pressure/tilt from the platform (see `stylus`). pub stylus: stylus::Stylus, + control_click: control_click::ControlClick, /// Run long commands and file opens as background jobs with progress and Cancel (#210; see /// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as /// before, which tests and scripts rely on. @@ -936,8 +1023,11 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, move_hover: None, + move_distance: None, object_finder: None, blend_preview: None, + reorder_preview: None, + reorder_preview_hit: false, patch_preview: None, cam_preview: None, magnetic: Default::default(), @@ -982,9 +1072,11 @@ impl PhotocraftApp { proxy_uploaded: None, outline_cache: None, filter_preview: None, + smart_filter_targets: Default::default(), #[cfg(not(target_arch = "wasm32"))] filter_preview_worker: Default::default(), redraws: Default::default(), + kept: Default::default(), color_range: None, adjust_preview: None, live_adjust_preview: None, @@ -1029,6 +1121,11 @@ impl PhotocraftApp { doc_hist: None, hist_job: None, gpu: None, + window_device: None, + window_lost: false, + window_recovery_retry: None, + gpu_render_state: None, + gpu_fault: None, started: None, perf: Default::default(), stroke_trace: stroke_trace::StrokeTrace::from_env(), @@ -1039,10 +1136,12 @@ impl PhotocraftApp { save_dirs: HashMap::new(), disk_stamps: HashMap::new(), targa_options: None, + jpeg_options: None, dds_options: None, held_opens: Default::default(), allow_close: false, stylus: Default::default(), + control_click: Default::default(), background_jobs: false, min_frame_ms: None, last_frame_start_ms: 0.0, @@ -1052,6 +1151,7 @@ impl PhotocraftApp { }; // Saved preferences are in place before the first frame; recovery starts in upkeep. prefs_ui::load(&mut app); + layers_panel_ui::restore_options(&mut app); // After the saved preferences, which say whether the set was already offered. kys_import::offer_import(&mut app); notices::wayland_file_drop_guidance(&mut app); @@ -1070,6 +1170,7 @@ impl PhotocraftApp { // The browser has no adapter list (egui-wgpu only has it natively). #[cfg(not(target_arch = "wasm32"))] self.perf.gpu_info.set_available(&rs.available_adapters); + self.gpu_render_state = Some(rs.clone()); let gpu = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| gpu_canvas::GpuCanvas::with_tile(&rs, Some(tile)))) { Ok(gpu) => gpu, Err(payload) => { @@ -1081,10 +1182,12 @@ impl PhotocraftApp { self.perf.gpu_info.canvas = "cpu".into(); self.perf.gpu_info.fallback = Some(detail.clone()); gpu_status::queue_fallback_notice(self, detail); + self.watch_window_device(&rs.device); return; } }; self.perf.gpu_info.canvas = "gpu".into(); + self.window_device = Some((rs.device.clone(), gpu.health().clone())); self.gpu = Some(gpu); self.prefs_rt.gpu_style = None; } @@ -1100,6 +1203,24 @@ impl PhotocraftApp { self.gpu.as_ref().map(|g| g.health().clone()) } + /// Watch the device the window draws with when the GPU canvas doesn't (the CPU canvas), so + /// its loss is handled like the GPU canvas's (see `gpu_status::check_window`). + pub fn watch_window_device(&mut self, device: &eframe::wgpu::Device) { + self.window_device = Some((device.clone(), photocraft_gpu::DeviceHealth::watch(device))); + } + + /// Whether the window's graphics device was lost, so the app saves its documents for + /// recovery and closes once they are saved (#3438). + pub fn window_lost(&self) -> bool { + self.window_lost + } + + /// Destroy the window's graphics device, as a driver reset does (`ui.gpu.simulateLoss` + /// with `destroy`, tests). Returns whether there was one. + pub fn destroy_window_device(&mut self) -> bool { + self.window_device.as_ref().map(|(device, _)| device.destroy()).is_some() + } + /// Run `hook` once the app has rendered its first frames (and a document opened at launch /// has drawn): the desktop app clears its crash-safe GPU startup marker there. pub fn on_started(&mut self, hook: impl FnOnce(&mut PhotocraftApp) + 'static) { @@ -1149,6 +1270,9 @@ impl PhotocraftApp { } fn run_command(&mut self, id: &str, params: Value) -> Result { + if !vector_ui::cancel_stale(self) { + return Err(self.ui.status.clone()); + } if matches!(id, "paint.setSymmetry" | "paint.symmetryFromPath" | "paint.symmetryDisable") { self.ui.symmetry_transform = None; } @@ -1173,6 +1297,7 @@ impl PhotocraftApp { return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() }); } } + actions::suspended_guard(self, id)?; if let Some(result) = panel_docking::command(self, id, ¶ms) { return result; } @@ -1220,10 +1345,6 @@ impl PhotocraftApp { id, "layer.vectorMask.add" | "layer.vectorMask.fromPath" | "layer.vectorMask.revealAll" | "layer.vectorMask.hideAll" | "layer.vectorMask.currentPath" ) && on_active_layer; - // A draft Pen path belongs to the current document, not the one about to be created. - if id == "file.new" && self.ui.pen.is_some() { - vector_ui::pen_commit(self, false); - } let r = if id == "actions.play" { actions::play(self, ¶ms) } else if photocraft_engine::actions_cmds::shell_view_command(id) { @@ -1232,13 +1353,24 @@ impl PhotocraftApp { jobs_ui::run(self, id, params) }; let creates_adjustment_or_fill = id.starts_with("layer.newAdjustmentLayer.") || id.starts_with("layer.newFillLayer."); - if r.is_ok() && (ADDS_LAYER_MASK.contains(&id) || creates_adjustment_or_fill) { - // Adding a layer mask targets it, as in Photoshop (#2166). + if r.is_ok() && ADDS_LAYER_MASK.contains(&id) { + // Adding an explicit mask targets it, as in Photoshop (#2166). self.ui.mask_target = true; self.ui.vector_mask_target = false; - // Properties still shows the new adjustment or fill layer's settings, not its mask, - // as in Photoshop (#3369): clicking the mask thumbnail switches it to the mask. - self.ui.properties_layer = if creates_adjustment_or_fill { self.session.active().and_then(|st| st.active_layer).map(|l| l.0) } else { None }; + self.ui.properties_layer = None; + } else if r.is_ok() && creates_adjustment_or_fill { + // Panel Options may suppress a fill layer's default mask. Never target a + // nonexistent mask; an active path creates a vector mask instead. + let masks = self + .session + .active() + .and_then(|st| st.active_layer.and_then(|id| st.doc.layer(id))) + .map(|layer| (layer.mask.is_some(), layer.vector_mask.is_some())) + .unwrap_or((false, false)); + self.ui.mask_target = masks.0; + self.ui.vector_mask_target = !masks.0 && masks.1; + // Keep Properties on the new layer contents, as on main (#3369). + self.ui.properties_layer = self.session.active().and_then(|st| st.active_layer).map(|l| l.0); } if r.is_ok() && creates_adjustment_or_fill { // Properties comes to the front, shown and expanded, so the new layer's settings can @@ -1301,18 +1433,18 @@ impl PhotocraftApp { self.perf.command_ms = gpu_canvas::now_ms() - t0; match &r { Ok(_) => { + if self.ui.status_error { + self.ui.status.clear(); + self.ui.status_error = false; + } self.sync_views(); let removed = self.session.take_samplers_removed(); - if removed > 0 { + if removed > 0 && !self.ui.status_error { // A canvas-size change (crop, Canvas Size, Image Size, rotation) dropped Color // Sampler points that fell outside the new canvas (#1046). self.ui.status = crate::i18n::trn(crate::i18n::current(), removed as u64, "{n} sample point removed", "{n} sample points removed"); self.ui.status_error = false; } - if self.ui.status_error { - self.ui.status.clear(); - self.ui.status_error = false; - } } Err(e) => { self.ui.status = e.clone(); @@ -1345,6 +1477,9 @@ impl PhotocraftApp { // ... and to the document it was started on. crate::canvas::polygon_cancel_stale(self); crate::crop_ui::cancel_stale(self); + crate::perspective_crop_ui::cancel_stale(self); + // A pen path under construction belongs to the document it was started on too. + vector_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. let now = |i: usize| match self.view_docs.get(i) { @@ -1395,6 +1530,26 @@ impl PhotocraftApp { self.session.prefs.edit(|p| p.file_handling.recent_files = r); } + /// Records a picked type family for the font menu's Recent group, capped by + /// Preferences ▸ Type ▸ Recent Fonts (0 keeps no list), most recent first, de-duplicated. + pub fn note_recent_font(&mut self, family: &str) { + let cap = self.session.prefs().type_.recent_fonts.min(50) as usize; + let r = &mut self.ui.recent_fonts; + r.retain(|f| f != family); + if cap == 0 { + r.clear(); + return; + } + r.insert(0, family.to_string()); + r.truncate(cap); + } + + /// Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) applies to the existing list at + /// once, not only after the next pick. + pub fn sync_recent_fonts(&mut self) { + self.ui.recent_fonts.truncate(self.session.prefs().type_.recent_fonts.min(50) as usize); + } + /// File › Open Recent › Clear Recent File List. pub fn clear_recent(&mut self) { self.ui.recent_files.clear(); @@ -1446,8 +1601,10 @@ impl PhotocraftApp { return Ok(Vec::new()); } let import = self.services.import.as_ref().ok_or("no importer configured")?; - let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; - let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; + let opts = photocraft_engine::jobs::import_options(self.session.prefs()); + let (mut doc, warnings) = import(name, bytes, &opts)?; + // Preferences › Raw Defaults › Resolution, as the engine's open applies it. + photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc); // Edit › Color Settings policies apply on open; Ask When Opening holds the file back // until its prompt is answered, and later files wait behind it (`open_prompt`). // Missing fonts are read from the document itself: it may not be open yet (#4559). @@ -1496,8 +1653,9 @@ impl PhotocraftApp { /// events and no Color Settings policy (which may read user-configured profile paths). fn import_automation_document(&mut self, name: &str, bytes: &[u8]) -> Result, String> { let import = self.services.import.as_ref().ok_or("no importer configured")?; - let max_svg_group_depth = self.session.prefs().file_handling.rasterize_svg_groups_deeper_than as usize; - let (doc, warnings) = import(name, bytes, max_svg_group_depth)?; + let opts = photocraft_engine::jobs::import_options(self.session.prefs()); + let (mut doc, warnings) = import(name, bytes, &opts)?; + photocraft_engine::file_cmds::raw_defaults(&self.session, &warnings, &mut doc); // The caller records the path it read from. self.session.add_document(doc, None); if let Some(st) = self.session.active_mut() { @@ -1517,9 +1675,9 @@ impl PhotocraftApp { self.save_as_with(path, FormatChoices::default()) } - /// [`Self::save_as`], writing a Targa or DDS texture with the `choices` given instead of - /// asking for them (no Targa or DDS Options prompt): `file.save` and `file.saveAs` with - /// `tgaBits`, `ddsCompression` or `ddsMipmaps`. + /// [`Self::save_as`] with the format choices already made, which skip their prompts: + /// `file.save` and `file.saveAs` with `tgaBits` (no Targa Options), `ddsCompression` or + /// `ddsMipmaps` (no DDS Options), or `quality` or `jpegFormat` (no JPEG Options). pub fn save_as_with(&mut self, path: Option, choices: FormatChoices) -> Result { self.save_dialog_flow(path, choices, false) } @@ -1529,7 +1687,7 @@ impl PhotocraftApp { /// which writes whichever file the action is played on, not as a Save As to a fixed path. pub(crate) fn save_dialog_flow(&mut self, path: Option, choices: FormatChoices, plain_save: bool) -> Result { if self.save_options_open() { - return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } // Edit Contents documents save back into their smart object. if path.is_none() && self.session.is_enabled("layer.smartObjects.saveContents") { @@ -1573,7 +1731,7 @@ impl PhotocraftApp { // Confirming the document's own file: ask first if another app changed it (#6596). if plain_save && own.as_deref() == Some(path.as_str()) - && let Some(r) = crate::disk_change_prompt::intercept(app, doc, &path, true) + && let Some(r) = crate::disk_change_prompt::intercept(app, doc, &path, true, choices) { return r; } @@ -1588,9 +1746,10 @@ impl PhotocraftApp { /// [`Self::save_as_with`] once the path is known. pub(crate) fn save_to(&mut self, path: String, choices: FormatChoices) -> Result { - // An RGB Targa asks for its bits per pixel, and a DDS texture for its compression, unless - // the caller chose them; the save continues from the prompt (still a copy when it would - // be one without the prompt), which records the step once answered. + // An RGB Targa asks for its bits per pixel, a DDS texture for its compression, and a JPEG + // for its quality and format, unless the caller chose them; the save continues from the + // prompt (still a copy when it would be one without the prompt), which records the step + // once answered. if choices.tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { let copy = self.saves_copy(&path, None)?; targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?; @@ -1601,6 +1760,11 @@ impl PhotocraftApp { dds_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } + if choices.jpeg.is_none() && jpeg_options_ui::wants_prompt(self, &path) { + let copy = self.saves_copy(&path, None)?; + jpeg_options_ui::park(self, path.clone(), copy, "file.saveAs")?; + return Ok(serde_json::json!({"path": path, "warnings": []})); + } // A layered TIFF asks about its layers first (Preferences › File Handling); the save // continues from the prompt, which records the step once answered. if tiff_options_ui::wants_prompt(self, &path) { @@ -1611,20 +1775,23 @@ impl PhotocraftApp { } /// File › Save back to the document's own file, recorded as a `file.save` step with the - /// Targa or DDS answer in `choices` (a replayed Save of a flat file brings its own). + /// Targa, DDS or JPEG answer in `choices` (a replayed or scripted Save of a flat file brings + /// its own). pub(crate) fn save_in_place(&mut self, path: String, choices: FormatChoices) -> Result { if self.save_options_open() { - return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } let r = self.write_save(path, None, choices)?; - let mut step = Value::Object(Default::default()); - choices.record(&mut step); + // JPEG Options are recorded only where they change something + // ([`jpeg_options_ui::plain_save_step`]); the Targa and DDS answers as given. + let mut step = jpeg_options_ui::plain_save_step(choices.jpeg); + FormatChoices { jpeg: None, ..choices }.record(&mut step); self.session.journal.push(("file.save".into(), step)); Ok(r) } /// Writes the Save As and journals it as a `file.saveAs` step with its path (and TIFF, - /// Targa or DDS answer), so an action being recorded keeps it (#2032). + /// Targa, DDS or JPEG answer), so an action being recorded keeps it (#2032). fn save_as_step(&mut self, path: String, tiff_layers: Option, choices: FormatChoices) -> Result { let r = self.write_save(path.clone(), tiff_layers, choices)?; let mut step = serde_json::json!({"path": path}); @@ -1637,20 +1804,26 @@ impl PhotocraftApp { } /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]), - /// keeping its Targa or DDS answer so playback writes the file the same way. A save parked - /// behind Targa or DDS Options hasn't been journaled yet: it is recorded as File › Save once - /// answered. + /// keeping its Targa, DDS or JPEG answer so playback writes the file the same way. A save + /// parked behind Targa, DDS or JPEG Options hasn't been journaled yet: it is recorded as + /// File › Save once answered. pub(crate) fn journal_as_plain_save(&mut self, path: &str) { - let parked = [self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)), self.dds_options.as_mut().map(|p| (&p.path, &mut p.record))]; + let parked = [ + self.targa_options.as_mut().map(|p| (&p.path, &mut p.record)), + self.dds_options.as_mut().map(|p| (&p.path, &mut p.record)), + self.jpeg_options.as_mut().map(|p| (&p.path, &mut p.record)), + ]; if let Some((_, record)) = parked.into_iter().flatten().find(|(p, record)| p.as_str() == path && **record == "file.saveAs") { *record = "file.save"; return; } + // Only the path goes: a script's format answer stays, so a replay writes the file it is + // played on the same way. Any other answer (TIFF layers) keeps the Save As. if let Some(last) = self.session.journal.last_mut() && last.0 == "file.saveAs" && last.1.get("path").and_then(Value::as_str) == Some(path) && let Some(step) = last.1.as_object() - && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps")) + && step.keys().all(|k| matches!(k.as_str(), "path" | "tgaBits" | "ddsCompression" | "ddsMipmaps" | "quality" | "jpegFormat")) { let mut step = step.clone(); step.remove("path"); @@ -1658,18 +1831,19 @@ impl PhotocraftApp { } } - /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or prompt, and the - /// recorded TIFF, Targa or DDS answer (if any) stands in for the TIFF, Targa or DDS Options prompt. + /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` / `file.export.exportAs` + /// step: no dialog or prompt, and the recorded TIFF, Targa, DDS or JPEG answer (if any) stands + /// in for its prompt. pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { if self.save_options_open() { - return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + return Err(SAVE_OPTIONS_OPEN.into()); } if id == "file.save" { // The file the action is played on: one that holds the whole document, or a flat // file a recorded Save confirmed in the dialog while the document is still a plain - // raster, so it is never flattened over (#3531, #3806). A flat file's recorded Targa - // or DDS answer stands in for the prompt. - let choices = FormatChoices::from_params(params, id)?; + // raster, so it is never flattened over (#3531, #3806). A flat file's recorded Targa, + // DDS or JPEG answer writes the same way, without asking. + let choices = FormatChoices::from_step(params, id)?; let st = self.session.active().ok_or("no document")?; let flat_own = |p: &str| { !photocraft_engine::file_cmds::saves_in_place(p) @@ -1681,8 +1855,12 @@ impl PhotocraftApp { )?; return self.save_in_place(path, choices); } + if id == crate::export_dialog::COMMAND { + // Export As has its own settings, its TGA bits included (#7207). + return crate::export_dialog::replay(self, params); + } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; - let choices = FormatChoices::from_params(params, id)?; + let choices = FormatChoices::from_step(params, id)?; if id == "file.saveACopy" { // A copy: the document keeps its own file and unsaved state, as when recorded (#4373). let doc = self.session.active().ok_or("no document")?.doc.clone(); @@ -1708,6 +1886,7 @@ impl PhotocraftApp { settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless); settings.webp_quality = e.webp_quality.map(|q| q.min(100)); settings.document_path = st.path.as_deref().map(std::path::PathBuf::from); + settings.pcraft_cache = Some(st.pcraft_cache.clone()); } settings } @@ -1728,8 +1907,9 @@ impl PhotocraftApp { } /// Writes the active document to a known path. `tiff_layers` is the TIFF Options answer: - /// `Some(false)` discards the layers and saves a copy. `choices` are the Targa bits per - /// pixel (`None`: 32 with an alpha channel or transparency, else 24) and the DDS options. + /// `Some(false)` discards the layers and saves a copy. `choices` are the Targa bits per pixel + /// (`None`: 32 with an alpha channel or transparency, else 24), the DDS options and the JPEG + /// Options. fn write_save(&mut self, path: String, tiff_layers: Option, choices: FormatChoices) -> Result { let copy = self.saves_copy(&path, tiff_layers)?; let settings = choices.apply(ExportSettings { tiff_layers: tiff_layers.unwrap_or(ExportSettings::default().tiff_layers), ..self.save_settings() }); @@ -1761,6 +1941,50 @@ impl PhotocraftApp { Ok(Some((path, warnings))) } + /// After `saver` was written to `path`, any other open document bound to that file no longer + /// matches it: it loses the file and is marked unsaved, so it can't stay "saved" while its file + /// holds something else, closing it asks first, and its own Save asks for a name instead of + /// writing over the new file (#6597). Paths are compared as files, so another letter case on a + /// case-insensitive volume or a hard link counts. A notice names the documents affected. + pub(crate) fn detach_others_from_file(&mut self, path: &str, saver: DocId) { + let others: Vec<(DocId, String)> = self + .session + .documents() + .iter() + .filter(|st| st.doc.id != saver && st.path.as_deref().is_some_and(|p| photocraft_engine::file_cmds::same_file(p, path))) + .map(|st| (st.doc.id, st.doc.name.clone())) + .collect(); + let mut detached = Vec::new(); + for (id, name) in others { + let done = self.with_document(id, |app| { + let st = app.session.active_mut().ok_or("no document")?; + st.path = None; + // No revision ever equals this, so the document reads as unsaved until saved again. + st.saved_revision = u64::MAX; + Ok(()) + }); + if done.is_ok() { + detached.push(name); + } + } + if detached.is_empty() { + return; + } + let name = file_open::display_name(path); + let title = { + let template = "{name} was saved over from another tab"; + crate::i18n::fmt(tl!(template), &[("name", name.as_str())]) + }; + let lines = detached + .iter() + .map(|doc| { + let template = "\"{doc}\" no longer has a file and is marked unsaved: save it under a new name to keep it"; + crate::i18n::fmt(tl!(template), &[("doc", doc.as_str())]) + }) + .collect(); + notices::post(self, title, lines, false, None); + } + /// Record a written save of document `doc` as it was at `revision`: its name, path and saved /// state (unless a copy), the status, script events and the warnings. pub(crate) fn saved(&mut self, doc: DocId, revision: u64, path: &str, warnings: &[String], copy: bool) { @@ -1783,6 +2007,8 @@ impl PhotocraftApp { } }; self.ui.status = crate::i18n::fmt(tl!("Saved {name}"), &[("name", path)]); + // The file was written, copy or not: another tab bound to it no longer matches it. + self.detach_others_from_file(path, doc); if !copy { // A background save may finish while another document is active (or after its // document was closed, when there is nothing left to record). @@ -1838,9 +2064,30 @@ impl PhotocraftApp { own == path && self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)).is_some_and(|now| now != *then) } - /// TIFF, Targa or DDS Options is waiting on an answer before a save is written. + /// TIFF, Targa, DDS or JPEG Options is waiting on an answer before a save is written. pub(crate) fn save_options_open(&self) -> bool { - self.tiff_options.is_some() || self.targa_options.is_some() || self.dds_options.is_some() + self.tiff_options.is_some() || self.targa_options.is_some() || self.dds_options.is_some() || self.jpeg_options.is_some() + } + + /// Writes a save that waited on a format prompt (Targa, DDS or JPEG Options) once answered: + /// journals `step` as its command (`file.saveAs` for Save As, `file.saveACopy` for Save a + /// Copy, `file.save` for a plain Save), so an action being recorded keeps it with this answer + /// (#2032, #4373), reports a failure in the status bar, and releases a close prompt waiting on + /// the save. + pub(crate) fn write_answered_save(&mut self, ctx: &egui::Context, doc: DocId, path: String, settings: &ExportSettings, copy: bool, step: (&str, Value)) { + let written = self.with_document(doc, |app| app.write_document(path, settings, copy)); + if written.is_ok() { + self.session.journal.push((step.0.into(), step.1)); + } + match written { + Err(e) => { + self.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); + self.ui.status_error = true; + } + Ok(Some(_)) if !copy => crate::discard_ui::saved_document(self, ctx, doc), + // A background save releases the close prompt when it finishes (`jobs_ui`). + Ok(_) => {} + } } /// A print worker may still be handing a job to the system queue. @@ -1860,9 +2107,9 @@ impl PhotocraftApp { self.save_automation_with(path, FormatChoices::default()) } - /// [`Self::save_automation`] writing a Targa or DDS texture with the `choices` given (a - /// Targa's `None`: 32 with an alpha channel or transparency, else 24; a DDS texture's: - /// uncompressed without mipmaps). Automation saves never prompt. + /// [`Self::save_automation`] writing with the format `choices` given (a Targa's `None`: 32 + /// with an alpha channel or transparency, else 24; a DDS texture's: uncompressed without + /// mipmaps; a JPEG's: the file's own quality, Baseline). Automation saves never prompt. pub fn save_automation_with(&mut self, path: Option, choices: FormatChoices) -> Result { let target = { let state = self.session.active().ok_or("no document")?; @@ -2016,6 +2263,8 @@ impl eframe::App for PhotocraftApp { if self.ui.text_edit.as_ref().is_some_and(|ed| !self.ui.tool.is_type() || ed.mask != self.ui.tool.is_type_mask()) { type_tool::commit(self); } + // So does selecting another layer or document by any route (#6483). + type_tool::end_if_left(self); if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) { vector_ui::pen_commit(self, false); } @@ -2094,6 +2343,7 @@ impl eframe::App for PhotocraftApp { wheel_nav::fold_legacy_pinch(ctx, raw_input); } raw_input.events.extend(self.take_synthetic_step()); + self.control_click.normalize(&mut raw_input.events, cfg!(target_os = "macos")); // A pen held still is no touch screen's press-and-hold right click (stylus.rs). let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); stylus::pen_hold_is_no_right_click(ctx, pen_down); @@ -2122,8 +2372,13 @@ impl eframe::App for PhotocraftApp { self.stroke_trace.cpu_prev(frame.info().cpu_usage); // A text field's right-click menu, before any field is drawn (text_field_menu.rs). text_field_menu::show(&ctx); + // The wheel goes to a focused value field before the canvas or a panel sees it (#5473). + field_wheel::begin_frame(&ctx); // Each painting tool keeps its own brush (#218), so switch the active tool's brush in // before anything this frame reads it (the cursor, the options bar, a stroke). + // Preferences ▸ Type ▸ Recent Fonts: a smaller cap (or 0) trims the existing list at + // once, not only after the next pick. + self.sync_recent_fonts(); paint_mouse::sync_tool_brush(self); // A unit typed into a value field (`1in` in a px field) converts at the active document's // resolution, as in Photoshop. @@ -2141,6 +2396,7 @@ impl eframe::App for PhotocraftApp { } }); self.automation_input = false; + gpu_status::check_window(self, &ctx); return; } if screen_picker::busy(&ctx) { @@ -2151,19 +2407,24 @@ impl eframe::App for PhotocraftApp { // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); let chrome = !self.ui.view.hides_chrome(); - if chrome && self.ui.panels.menu_bar { + if chrome { + self.ui.panels.full_screen_chrome = false; + } + // In Full Screen Mode, Tab shows the menu bar, options bar, toolbar and dock (#6760). + let tabbed_in = !chrome && self.ui.panels.full_screen_chrome; + if (chrome || tabbed_in) && self.ui.panels.menu_bar { panels::title_bar(self, ui); } - if chrome && self.ui.panels.options_bar { + if (chrome && self.ui.panels.options_bar) || tabbed_in { panels::options_bar(self, ui); } if chrome && self.ui.panels.status_bar { panels::status_bar(self, ui); } - if chrome && self.ui.panels.toolbar { + if (chrome && self.ui.panels.toolbar) || tabbed_in { panels::toolbar(self, ui); } - if chrome && self.ui.panels.dock { + if (chrome && self.ui.panels.dock) || tabbed_in { panels::right_dock(self, ui); } let t = theme::Tokens::get(&ctx); @@ -2181,11 +2442,13 @@ impl eframe::App for PhotocraftApp { workspace_ui::windows(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + layers_panel_ui::show_dialog(self, &ctx); rasterize_prompt::run_pending(self, &ctx); jobs_ui::dialog(self, &ctx); discard_ui::show(self, &ctx); tiff_options_ui::show(self, &ctx); targa_options_ui::show(self, &ctx); + jpeg_options_ui::show(self, &ctx); dds_options_ui::show(self, &ctx); distort_ui::show(self, &ctx); camera_raw_ui::show(self, &ctx); @@ -2258,6 +2521,8 @@ impl eframe::App for PhotocraftApp { jobs_ui::keep_polling(self, &ctx); // Menus and buttons in this frame may have asked for a file dialog. self.poll_file_dialog(&ctx, Some(frame)); + // Last, after everything drawn this frame: a lost window device can't draw it (#3438). + gpu_status::check_window(self, &ctx); } } @@ -2373,6 +2638,9 @@ impl PhotocraftApp { if layer.vector_mask.is_some() { live.insert((layer.id, mask_thumbs_ui::THUMB_VECTOR)); } + if matches!(&layer.content, photocraft_doc::LayerContent::Smart(sm) if sm.filter_mask.is_some()) { + live.insert((layer.id, mask_thumbs_ui::THUMB_SMART_MASK)); + } if let Some(children) = layer.children() { pending.extend(children); } @@ -2456,9 +2724,22 @@ impl PhotocraftApp { id } + /// The cached thumbnail of `mask`, the layer mask of layer `id`. pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId { - let rev = surface_fingerprint(&mask.surface) ^ (doc.size.width as u64) << 40; - let key = (id, mask_thumbs_ui::THUMB_MASK); + self.mask_thumb_kind(ctx, doc, id, mask, mask_thumbs_ui::THUMB_MASK) + } + + /// The thumbnail of `mask` under cache `kind`, re-rendered only when the mask's pixels or the document size change; Smart Filter and layer masks are cached apart. + pub(crate) fn mask_thumb_kind( + &mut self, + ctx: &egui::Context, + doc: &Document, + id: photocraft_doc::LayerId, + mask: &photocraft_doc::LayerMask, + kind: u8, + ) -> egui::TextureId { + let rev = surface_fingerprint(&mask.surface) ^ (doc.size.width as u64) << 40 ^ (doc.size.height as u64) << 16; + let key = (id, kind); if let Some((r, tex)) = self.thumbs.get(&key) && *r == rev { @@ -2587,6 +2868,31 @@ impl PhotocraftApp { self.bounds_cache.insert(key, (rev, b)); b } + + /// The box Properties › Transform shows for `layer` (X, Y, W, H): its pixels' bounds, but a + /// soft shape's path and stroke, whose Density and Feather render past them (#5846), as its + /// transform frame does. + pub fn layer_frame_bounds(&mut self, layer: &photocraft_doc::Layer, surface: &photocraft_raster::Surface) -> photocraft_geom::Rect { + match &layer.content { + photocraft_doc::LayerContent::Shape(sh) if sh.has_soft_mask() => { + // Cached per rendering, like the pixel bounds: the frame renders strips of the shape. + let (key, rev) = (layer.id.0 | 1 << 63, surface_fingerprint(surface)); + if let Some((r, b)) = self.bounds_cache.get(&key) + && *r == rev + { + return *b; + } + let canvas = self.session.active().map_or_else(|| surface.content_bounds(), |st| st.doc.bounds()); + let b = photocraft_compose::soft_shape_frame(sh, surface.format(), canvas).unwrap_or(photocraft_geom::Rect::EMPTY); + if self.bounds_cache.len() > 256 { + self.bounds_cache.clear(); + } + self.bounds_cache.insert(key, (rev, b)); + b + } + _ => self.cached_bounds(layer.id.0, surface), + } + } } impl PhotocraftApp { @@ -2893,6 +3199,8 @@ mod color_replacement_tests; #[cfg(test)] mod layer_shortcut_tests; #[cfg(test)] +mod live_retouch_tests; +#[cfg(test)] mod pencil_tests; #[cfg(test)] mod tool_shortcut_tests; @@ -2906,6 +3214,8 @@ mod transform_fx_tests; #[cfg(test)] mod layer_props_ui_tests; #[cfg(test)] +mod notice_text_tests; +#[cfg(test)] mod transform_switch_tests; #[cfg(test)] mod transform_undo_tests; @@ -2956,10 +3266,15 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod paths_scroll_tests; #[cfg(test)] mod options_bar_mode_wheel_tests; +#[cfg(test)] +mod field_wheel_app_tests; + #[cfg(test)] mod dropdown_press_tests; @@ -3480,5 +3795,9 @@ mod channel_tint_tests { } } +#[cfg(test)] +mod browse_field_tests; #[cfg(test)] mod options_hit_area_tests; +#[cfg(test)] +mod select_and_mask_button_tests; diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 9f46020b11..cf1c932518 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -192,6 +192,8 @@ pub struct LiquifyDialog { pub dab_ms: f64, /// CPU time spent refreshing the zoomed-in source-resolution preview. pub preview_ms: f64, + /// The smart filter being edited: OK updates it instead of adding a new Liquify filter. + smart_filter: Option, } fn union(a: Option<[usize; 4]>, b: [usize; 4]) -> Option<[usize; 4]> { @@ -228,6 +230,7 @@ impl LiquifyDialog { "dabMs": self.dab_ms, "fullResolutionPreview": self.view_bounds.is_some(), "previewMs": self.preview_ms, + "smartFilter": self.smart_filter, }) } @@ -255,6 +258,8 @@ impl LiquifyDialog { /// Starts a stroke; `alt` draws with the selected tool's opposite (see [`alt_tool`]). fn begin(&mut self, p: [f64; 3], now: f64, alt: bool) { + // A stroke still open has already changed the field; record it so undo can replay it (#8055). + self.end(); self.redo.clear(); let tool = if alt { alt_tool(self.opts.tool) } else { self.opts.tool }; let mut s = self.template(tool); @@ -484,10 +489,42 @@ impl LiquifyDialog { /// Opens the dialog on the active layer. pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; + let canvas = app.session.active().ok_or("no document")?.doc.bounds(); + open_on(app, ctx, layer, surf, Vec::new(), auto_cell(canvas), None) +} + +/// Re-opens Liquify smart filter `index` of smart object `layer` with its recorded strokes and +/// mesh size, previewing on the pixels below it; OK updates that filter +/// (`layer.smartFilter.setParams`). +pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> { + use photocraft_engine::distort_cmds::{LIQUIFY, liquify_params}; + let st = app.session.active().ok_or("no document")?; + let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { + return Err("the layer is not a smart object".into()); + }; + let filter = sm.smart_filters.get(index).ok_or("no such smart filter")?; + if filter.command != LIQUIFY { + return Err("the smart filter is not a Liquify filter".into()); + } + let (strokes, cell) = liquify_params(LIQUIFY, &filter.params, st.doc.bounds()).map_err(|e| e.to_string())?; + let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index) + .map_err(|e| e.to_string())? + .ok_or("the smart object's contents are unavailable (missing linked file?)")?; + open_on(app, ctx, layer, surf, strokes, cell, Some(index)) +} + +fn open_on( + app: &mut PhotocraftApp, + ctx: &egui::Context, + layer: LayerId, + surf: Surface, + strokes: Vec, + cell: f64, + smart_filter: Option, +) -> Result<(), String> { let st = app.session.active().ok_or("no document")?; let canvas = st.doc.bounds(); let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); - let cell = auto_cell(canvas); let proxy = ProxyImage::new(&surf, canvas, PROXY_SIDE); let out = vec![[0u8; 4]; proxy.w * proxy.h]; let center = [f64::from(canvas.x0 + canvas.x1) / 2.0, f64::from(canvas.y0 + canvas.y1) / 2.0]; @@ -496,8 +533,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> layer_name: name, canvas, cell, - field: LiquifyField::new(canvas, cell), - strokes: Vec::new(), + field: LiquifyField::from_strokes(canvas, cell, &strokes), + strokes, redo: Vec::new(), cur: None, lasso: None, @@ -522,6 +559,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> last_dab: 0.0, dab_ms: 0.0, preview_ms: 0.0, + smart_filter, }; // A sensible starting brush (about a tenth of the image), then the settings last used, so // Liquify opens as it was left (#418). Saved values are clamped; a bad one is skipped. @@ -550,22 +588,29 @@ pub fn cancel(app: &mut PhotocraftApp) { } } -/// OK: runs `filter.liquify` with the recorded strokes (one history step). -pub fn commit(app: &mut PhotocraftApp) { - let Some(mut d) = app.distort.liquify.take() else { return }; +/// OK: runs `filter.liquify` with the recorded strokes (one history step). When the dialog edits +/// a smart filter, OK updates that filter instead (also when Restore All left no strokes). The +/// dialog closes either way. `app.run` shows a failed command's error in the status bar, and +/// `commit` returns it. +pub fn commit(app: &mut PhotocraftApp) -> Result<(), String> { + let Some(mut d) = app.distort.liquify.take() else { return Ok(()) }; remember(app, &d.opts); d.end(); + if let Some(index) = d.smart_filter { + let params = json!({"strokes": d.strokes, "meshSize": d.cell}); + return app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params})).map(|_| ()); + } if d.strokes.is_empty() { - return; + return Ok(()); } let p = json!({"strokes": d.strokes, "meshSize": d.cell, "layer": d.layer.0}); - let _ = app.run("filter.liquify", p); + app.run("filter.liquify", p).map(|_| ()) } /// Control channel: `filter.liquify {"ui": {...}}` while the dialog is open. pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if ui.get("commit").and_then(Value::as_bool) == Some(true) { - commit(app); + commit(app)?; return Ok(json!({"committed": true})); } if ui.get("cancel").and_then(Value::as_bool) == Some(true) { @@ -616,6 +661,13 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { } } +/// Where the brush outline is drawn: under the pointer, except during an Alt-drag of the brush +/// size (`resize`), when it stays at the drag's anchor and only grows or shrinks, as the brush +/// tools' Alt-drag does outside Liquify (#7884). +fn brush_circle_at(resize: Option<(Pos2, f32, f32)>, hover: Option) -> Option { + resize.map(|(anchor, ..)| anchor).or(hover) +} + /// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { mods.alt && (secondary || mods.ctrl) @@ -683,7 +735,18 @@ pub fn history_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { history_keys(app, ctx); + // ⌘+ / ⌘− / ⌘0 / ⌘1 (or their overrides) zoom the preview, not the canvas behind it (#7210). + let nav = if app.distort.liquify.is_some() { crate::shortcuts::nav_shortcut(app, ctx) } else { None }; let Some(d) = app.distort.liquify.as_mut() else { return }; + if let Some(id) = nav { + d.zoom = match id { + "view.zoomIn" => crate::zoom_levels::step_in_range(d.zoom, 1, ZOOM_MIN, ZOOM_MAX), + "view.zoomOut" => crate::zoom_levels::step_in_range(d.zoom, -1, ZOOM_MIN, ZOOM_MAX), + // Fit on Screen: the preview fits and centres the image on its next frame. + "view.fitOnScreen" => 0.0, + _ => 1.0, + }; + } // Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active. if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { d.opts.show_mask = !d.opts.show_mask; @@ -836,6 +899,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let mut action: Option<&str> = None; let mut events: Vec = Vec::new(); let mut mods = egui::Modifiers::NONE; + let pinch_zooms = app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch; egui::Area::new(egui::Id::new("liquify-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| { let Some(d) = app.distort.liquify.as_mut() else { return }; d.upload(ctx); @@ -963,19 +1027,29 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { painter.rect_filled(area, 0.0, t.canvas); let (cw, ch) = (d.canvas.width() as f32, d.canvas.height() as f32); if d.zoom <= 0.0 { - d.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).clamp(0.01, 4.0); + d.zoom = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).clamp(ZOOM_MIN, 4.0); d.center = [f64::from(d.canvas.x0) + f64::from(cw) / 2.0, f64::from(d.canvas.y0) + f64::from(ch) / 2.0]; } let resp = ui.interact(area, egui::Id::new("liquify-preview"), Sense::click_and_drag()); - // Wheel zooms about the pointer. - if resp.hovered() { - let scroll = ui.input(|i| i.smooth_scroll_delta.y); - if scroll != 0.0 - && let Some(hp) = resp.hover_pos() - { + // The wheel zooms about the pointer, ×1.1 a notch, and so does a trackpad pinch (unless + // Preferences › Enhanced Controls › Zoom with Trackpad Pinch is off); a sideways scroll + // pans (#7210). Read every frame, as `wheel_nav` asks, so its modifier state follows. + let wheel = crate::wheel_nav::read(ctx, true, pinch_zooms, egui::Id::new("liquify-preview-wheel"), resp.hovered()); + if let Some(hp) = resp.hover_pos() { + let zoom = match wheel { + Some(crate::wheel_nav::Wheel::Zoom(f)) => Some((d.zoom * f).clamp(ZOOM_MIN, ZOOM_MAX)), + Some(crate::wheel_nav::Wheel::SnapZoom(n)) => { + Some((0..n.unsigned_abs()).fold(d.zoom, |z, _| crate::zoom_levels::step_in_range(z, n.signum(), ZOOM_MIN, ZOOM_MAX))) + } + Some(crate::wheel_nav::Wheel::Pan(v)) => { + d.center = [d.center[0] - f64::from(v.x / d.zoom), d.center[1] - f64::from(v.y / d.zoom)]; + None + } + None => None, + }; + if let Some(z) = zoom { let before = to_doc(d, area, hp); - // Liquify's own preview zoom, not the canvas (`zoom_levels` doesn't apply). - d.zoom = (d.zoom * (scroll / 200.0).exp()).clamp(0.01, 32.0); + d.zoom = z; let after = to_doc(d, area, hp); d.center = [d.center[0] + before[0] - after[0], d.center[1] + before[1] - after[1]]; } @@ -1036,8 +1110,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let line: Vec = pts.iter().map(|p| to_screen(d, area, *p)).collect(); clip.add(egui::Shape::line(line, Stroke::new(1.0, color))); } - // Brush outline (the lasso has none). - if let Some(hp) = resp.hover_pos() { + // Brush outline (the lasso has none). While Alt-dragging its size it stays where the drag + // started, so only its size changes (#7884). + if let Some(hp) = brush_circle_at(d.brush_resize, resp.hover_pos()) { if d.opts.tool != LiquifyTool::LassoMask { let r = d.opts.size * 0.5 * d.zoom; clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK)); @@ -1049,10 +1124,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Mouse → strokes (Space or middle button pans). Alt+right-drag resizes the brush (#4702). let (pan, secondary, mods_now) = ui.input(|i| (i.key_down(egui::Key::Space) || i.pointer.middle_down(), i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); - let resize = d.brush_resize.is_some() || (resp.drag_started() && brush_resize_gesture(mods_now, secondary)); + // From the press, not once the drag is recognised a few points later: that first frame + // would otherwise start a stroke. + let resize = d.brush_resize.is_some() || (resp.is_pointer_button_down_on() && brush_resize_gesture(mods_now, secondary)); if resize { + // Anchored where the button went down, not where the drag was recognised a few + // points later, so the outline doesn't jump when the gesture starts. if d.brush_resize.is_none() - && let Some(p) = resp.interact_pointer_pos() + && let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { d.brush_resize = Some((p, d.opts.size, d.opts.density)); } @@ -1078,7 +1157,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { events.push(ToolEvent::Move { x: q[0], y: q[1], pressure: pr }); ctx.request_repaint(); } - } else if resp.drag_started() || (resp.is_pointer_button_down_on() && d.cur.is_none()) { + } else if (resp.drag_started() || resp.is_pointer_button_down_on()) && d.cur.is_none() { + // The stroke already began at the press; egui's later drag start must not restart it (#8055). events.push(ToolEvent::Down { x: q[0], y: q[1], pressure: pr }); } else if resp.dragged() || resp.is_pointer_button_down_on() { let moved = d.cur.as_ref().is_some_and(|(_, l)| l[0] != q[0] || l[1] != q[1]); @@ -1101,12 +1181,19 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { pointer(app, ev, mods); } match action { - Some("ok") => commit(app), + Some("ok") => { + let _ = commit(app); + } Some("cancel") => cancel(app), _ => {} } } +/// The preview's zoom range (1 = the dialog's 100 %). Liquify keeps its own zoom, apart from the +/// canvas behind it. +const ZOOM_MIN: f32 = 0.01; +const ZOOM_MAX: f32 = 32.0; + fn to_screen(d: &LiquifyDialog, area: ERect, p: [f64; 2]) -> Pos2 { area.center() + vec2(((p[0] - d.center[0]) as f32) * d.zoom, ((p[1] - d.center[1]) as f32) * d.zoom) } @@ -1171,6 +1258,15 @@ fn draw_mesh(d: &LiquifyDialog, painter: &egui::Painter, area: ERect) { mod tests { use super::*; + #[test] + fn the_brush_outline_follows_the_pointer_except_while_resizing() { + let (hover, anchor) = (pos2(30.0, 40.0), pos2(10.0, 20.0)); + assert_eq!(brush_circle_at(None, Some(hover)), Some(hover)); + assert_eq!(brush_circle_at(Some((anchor, 40.0, 50.0)), Some(hover)), Some(anchor)); + assert_eq!(brush_circle_at(Some((anchor, 40.0, 50.0)), None), Some(anchor)); + assert_eq!(brush_circle_at(None, None), None); + } + #[test] fn alt_right_drag_resizes_the_liquify_brush_and_does_not_paint() { assert!(brush_resize_gesture(egui::Modifiers::ALT, true)); @@ -1297,7 +1393,7 @@ mod tests { }); // OK and Esc keep the latest settings too. control(&mut app, &json!({"density": 90})).unwrap(); - commit(&mut app); + commit(&mut app).unwrap(); open(&mut app, &ctx).unwrap(); assert_eq!(app.distort.liquify.as_ref().unwrap().opts.density, 90.0); control(&mut app, &json!({"density": 70})).unwrap(); @@ -1547,6 +1643,47 @@ mod tests { assert_eq!(d.redo.len(), REDO_STACK_LIMIT); } + /// #7884: Alt+right-dragging the brush size in the Liquify dialog keeps the brush where the + /// drag started (its outline and the point it would work at) and only changes its size, as the + /// brush tools' Alt-drag does outside Liquify. + #[test] + fn alt_drag_resize_keeps_the_brush_where_it_started() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let screen = ERect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 900.0)); + let frame = |app: &mut PhotocraftApp, events: Vec, mods: egui::Modifiers| { + let events = std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(); + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + ctx.run_ui(raw, |ui| show(app, ui.ctx())).textures_delta.clear(); + }; + let alt = egui::Modifiers::ALT; + let none = egui::Modifiers::NONE; + frame(&mut app, vec![], none); + // A point over the image in the dialog's canvas (120 × 80): the first one on a grid. + let p = (1..22) + .flat_map(|j| (1..34).map(move |i| pos2(40.0 * i as f32, 40.0 * j as f32))) + .find(|&q| { + frame(&mut app, vec![egui::Event::PointerMoved(q)], none); + let h = app.distort.liquify.as_ref().and_then(|d| d.hover); + h.is_some_and(|h| (10.0..60.0).contains(&h[0]) && (20.0..60.0).contains(&h[1])) + }) + .expect("the dialog shows the image"); + frame(&mut app, vec![egui::Event::PointerMoved(p)], none); + let d = app.distort.liquify.as_ref().unwrap(); + let (start, size0) = (d.hover.expect("the pointer is over the canvas"), d.opts.size); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: alt }; + frame(&mut app, vec![button(true)], alt); + for k in 1..=6 { + frame(&mut app, vec![egui::Event::PointerMoved(p + vec2(10.0 * k as f32, 0.0))], alt); + } + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.opts.size > size0, "the drag resized the brush: {} -> {}", size0, d.opts.size); + assert_eq!(d.hover, Some(start), "the brush stayed where the drag started"); + assert!(d.strokes.is_empty(), "and nothing was painted"); + } + #[test] fn shortcut_handler_undoes_liquify_even_when_egui_owns_keyboard_focus() { let ctx = egui::Context::default(); @@ -1586,6 +1723,160 @@ mod tests { assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1, "Cmd+Shift+Z redoes the last stroke"); } + fn key(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND } + } + + /// A frame of the open dialog in a 1000 × 700 window with `events`; returns the preview's + /// centre (between the tool strip and the options panel, under the title strip). + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, events: Vec) -> Pos2 { + let screen = ERect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + pos2((LEFT_W + screen.width() - RIGHT_W) / 2.0, (30.0 + screen.height()) / 2.0) + } + + /// #7210: ⌘+ / ⌘− step Liquify's preview along the zoom ladder, ⌘0 fits it and ⌘1 is the + /// dialog's 100 %, through the canvas's own shortcuts; the canvas behind keeps its zoom. + #[test] + fn zoom_shortcuts_zoom_the_preview_not_the_canvas() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + frame(&mut app, &ctx, Vec::new()); + let fitted = app.distort.liquify.as_ref().unwrap().zoom; + assert!(fitted > 0.0, "the preview fits on its first frame"); + let canvas_zoom = app.ui.views.first().map(|v| v.zoom); + let mut press = |k: egui::Key| { + let raw = egui::RawInput { events: vec![key(k)], ..Default::default() }; + let _ = ctx.run_ui(raw, |ui| crate::shortcuts::handle(&mut app, ui.ctx())); + app.distort.liquify.as_ref().unwrap().zoom + }; + assert_eq!(press(egui::Key::Num1), 1.0); + assert_eq!(press(egui::Key::Equals), 2.0, "next step up the ladder"); + assert_eq!(press(egui::Key::Plus), 3.0, "⌘+ typed as Plus too"); + assert_eq!(press(egui::Key::Minus), 2.0); + assert_eq!(press(egui::Key::Num0), 0.0, "fit on the next frame"); + frame(&mut app, &ctx, Vec::new()); + assert_eq!(app.distort.liquify.as_ref().unwrap().zoom, fitted); + assert_eq!(app.ui.views.first().map(|v| v.zoom), canvas_zoom, "the canvas behind keeps its zoom"); + } + + /// #7210: a trackpad pinch over the preview zooms it about the pointer (macOS sends no wheel + /// for it), unless Preferences › Enhanced Controls › Zoom with Trackpad Pinch is off; a wheel + /// notch is one ×1.1 step. + #[test] + fn pinch_and_wheel_zoom_the_preview_about_the_pointer() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + let center = frame(&mut app, &ctx, Vec::new()); + let at = center + vec2(60.0, -40.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let screen = ERect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let area = ERect::from_min_max(pos2(LEFT_W, 30.0), pos2(screen.width() - RIGHT_W, screen.height())); + let (z0, under) = { + let d = app.distort.liquify.as_ref().unwrap(); + (d.zoom, to_doc(d, area, at)) + }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), egui::Event::Zoom(2.0)]); + let d = app.distort.liquify.as_ref().unwrap(); + assert!((d.zoom - z0 * 2.0).abs() < 1e-4, "{} → {}", z0, d.zoom); + let still = to_doc(d, area, at); + assert!((still[0] - under[0]).abs() < 1e-3 && (still[1] - under[1]).abs() < 1e-3, "the point under the pointer stays: {under:?} → {still:?}"); + + let notch = ctx.options(|o| o.input_options.line_scroll_speed); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }; + let z1 = app.distort.liquify.as_ref().unwrap().zoom; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), wheel]); + let z2 = app.distort.liquify.as_ref().unwrap().zoom; + assert!(notch > 0.0 && (z2 / z1 - crate::wheel_nav::NOTCH).abs() < 1e-3, "one notch is ×1.1: {z1} → {z2}"); + + app.session.prefs.edit(|p| p.enhanced_controls.zoom_with_trackpad_pinch = false); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), egui::Event::Zoom(2.0)]); + assert_eq!(app.distort.liquify.as_ref().unwrap().zoom, z2, "a pinch does nothing with the preference off"); + } + + /// The proxy rendered from the recorded strokes, i.e. what undo/replay rebuilds. + fn replayed_out(d: &LiquifyDialog) -> Vec<[u8; 4]> { + let field = LiquifyField::from_strokes(d.canvas, d.cell, &d.strokes); + let mut out = vec![[0u8; 4]; d.proxy.w * d.proxy.h]; + d.proxy.render(&field, [0, 0, d.proxy.w, d.proxy.h], &mut out); + out + } + + /// #8055: a second pointer-down while a stroke is open must not drop the dabs already applied. + #[test] + fn second_down_without_up_keeps_the_open_strokes_dabs_recorded() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 30})).unwrap(); + for ev in [ + ToolEvent::Down { x: 40.0, y: 40.0, pressure: 1.0 }, + ToolEvent::Move { x: 46.0, y: 41.0, pressure: 1.0 }, + ToolEvent::Down { x: 46.0, y: 41.0, pressure: 1.0 }, + ToolEvent::Move { x: 55.0, y: 42.0, pressure: 1.0 }, + ToolEvent::Up { x: 55.0, y: 42.0 }, + ] { + pointer(&mut app, ev, egui::Modifiers::NONE); + } + let d = app.distort.liquify.as_ref().unwrap(); + assert!(d.out == replayed_out(d), "the live field must match the field rebuilt from the recorded strokes"); + } + + /// #8055: short strokes drawn with the mouse (moves inside egui's drag threshold first) are + /// recorded whole, as one stroke each, so undo removes only the last one. + #[test] + fn short_mouse_strokes_are_recorded_whole_and_undo_removes_only_the_last() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 30})).unwrap(); + let screen = ERect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 800.0)); + let frame = |app: &mut PhotocraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let mut before_last = Vec::new(); + let strokes = 6; + for i in 0..strokes { + let start = pos2(560.0 + i as f32 * 25.0, 380.0 + (i % 2) as f32 * 20.0); + before_last = app.distort.liquify.as_ref().unwrap().out.clone(); + frame(&mut app, vec![egui::Event::PointerMoved(start)]); + let press = egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![press]); + let mut p = start; + for _ in 0..8 { + p += vec2(1.5, 1.0); + frame(&mut app, vec![egui::Event::PointerMoved(p)]); + } + let release = egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![release]); + frame(&mut app, vec![]); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), i + 1, "one recorded stroke per mouse stroke"); + assert!(d.out == replayed_out(d), "stroke {i}: the live field must match the recorded strokes"); + } + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.strokes.len(), strokes - 1); + let differ = d.out.iter().zip(&before_last).filter(|(a, b)| a != b).count(); + assert_eq!(differ, 0, "undo restores the pixels from before the last stroke"); + } + #[test] fn menu_opens_the_dialog_and_routes_pointer_events() { let ctx = egui::Context::default(); @@ -1599,4 +1890,136 @@ mod tests { assert!(app.distort.liquify.is_none()); assert_eq!(app.session.active().unwrap().history.past_len(), 2, "cancel records nothing"); } + + /// A smart object with a Liquify smart filter (one stroke, mesh size 3) and a Gaussian Blur + /// above it. + fn smart_liquify_app() -> (PhotocraftApp, LayerId) { + let mut app = app_with_layer(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let stroke = json!({"tool": "forwardWarp", "size": 40, "points": [[40.0, 40.0, 1.0], [48.0, 41.0, 1.0], [56.0, 40.0, 1.0]]}); + app.run("filter.liquify", json!({"strokes": [stroke], "meshSize": 3.0})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2.0})).unwrap(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + (app, layer) + } + + fn smart(app: &PhotocraftApp, layer: LayerId) -> photocraft_doc::SmartObject { + match &app.session.active().unwrap().doc.layer(layer).unwrap().content { + photocraft_doc::LayerContent::Smart(sm) => sm.clone(), + _ => panic!("not a smart object"), + } + } + + fn drag(app: &mut PhotocraftApp, from: f64, to: f64) { + for ev in [ToolEvent::Down { x: from, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: to, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: to, y: 40.0 }] { + pointer(app, ev, egui::Modifiers::NONE); + } + } + + #[test] + fn smart_filter_double_click_edits_the_recorded_liquify() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + let steps = app.session.active().unwrap().history.past_len(); + let sm = smart(&app, layer); + let recorded: Vec = serde_json::from_value(sm.smart_filters[0].params["strokes"].clone()).unwrap(); + assert_eq!(recorded.len(), 1); + + crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0); + assert!(app.ui.dialogs.is_empty(), "no generic parameter form"); + let d = app.distort.liquify.as_ref().expect("the Liquify workspace opens"); + assert_eq!((d.smart_filter, d.cell), (Some(0), 3.0)); + assert_eq!(d.strokes, recorded); + assert_eq!(d.field, LiquifyField::from_strokes(d.canvas, 3.0, &recorded)); + // The preview starts from the pixels below the filter (no liquify, no blur). + let below = photocraft_engine::smart_cmds::render_below_filter(&app.session.active().unwrap().doc, &sm, 0).unwrap().unwrap(); + assert_eq!(d.source.read_region(d.canvas), below.read_region(d.canvas)); + + drag(&mut app, 70.0, 84.0); + control(&mut app, &json!({"commit": true})).unwrap(); + assert!(app.distort.liquify.is_none()); + let sm = smart(&app, layer); + assert_eq!(sm.smart_filters.len(), 2, "edited in place, not added"); + assert_eq!(sm.smart_filters[0].command, photocraft_engine::distort_cmds::LIQUIFY); + assert_eq!(sm.smart_filters[0].params["strokes"].as_array().map(Vec::len), Some(2)); + assert_eq!(sm.smart_filters[0].params["meshSize"], json!(3.0)); + assert_eq!(sm.smart_filters[1].command, "filter.blur.gaussianBlur"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[0].params["strokes"].as_array().map(Vec::len), Some(1)); + } + + #[test] + fn smart_filter_cancel_keeps_and_restore_all_clears_the_recorded_strokes() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + let steps = app.session.active().unwrap().history.past_len(); + let params = smart(&app, layer).smart_filters[0].params.clone(); + + open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); + drag(&mut app, 70.0, 84.0); + control(&mut app, &json!({"cancel": true})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[0].params, params, "Cancel leaves the filter as it was"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // OK after Restore All keeps the filter, with no strokes. + open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); + control(&mut app, &json!({"restoreAll": true})).unwrap(); + control(&mut app, &json!({"commit": true})).unwrap(); + let sm = smart(&app, layer); + assert_eq!(sm.smart_filters.len(), 2); + assert_eq!(sm.smart_filters[0].params["strokes"], json!([])); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + } + + #[test] + fn open_smart_filter_rejects_other_filters_and_layers() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + assert!(open_smart_filter(&mut app, &ctx, layer, 1).is_err(), "the Gaussian Blur"); + assert!(open_smart_filter(&mut app, &ctx, layer, 9).is_err(), "no such filter"); + let mut plain = app_with_layer(); + let pixels = plain.session.active().unwrap().active_layer.unwrap(); + assert!(open_smart_filter(&mut plain, &ctx, pixels, 0).is_err(), "not a smart object"); + assert!(app.distort.liquify.is_none() && plain.distort.liquify.is_none()); + } + + /// OK on a smart filter that is gone: the workspace closes, the status bar shows the error and + /// the control channel returns it. + #[test] + fn smart_filter_edit_that_cannot_be_applied_reports_the_error() { + let ctx = egui::Context::default(); + let (mut app, layer) = smart_liquify_app(); + open_smart_filter(&mut app, &ctx, layer, 0).unwrap(); + drag(&mut app, 70.0, 84.0); + app.session.execute("layer.smartFilter.clearSmartFilters", json!({"layer": layer.0})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let r = control(&mut app, &json!({"commit": true})); + assert!(app.distort.liquify.is_none()); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("no smart filters"), "{}", app.ui.status); + assert!(smart(&app, layer).smart_filters.is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!(r, Err(app.ui.status.clone())); + } + + /// OK after the layer was deleted: the dialog closes, the status bar shows the error and the + /// control channel returns it. + #[test] + fn liquify_that_cannot_be_applied_reports_the_error() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + let layer = app.session.active().unwrap().active_layer.unwrap(); + open(&mut app, &ctx).unwrap(); + drag(&mut app, 40.0, 56.0); + assert_eq!(app.distort.liquify.as_ref().unwrap().strokes.len(), 1); + app.session.execute("layer.delete", json!({"layer": layer.0})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let r = control(&mut app, &json!({"commit": true})); + assert!(app.distort.liquify.is_none()); + assert!(app.ui.status_error); + assert!(!app.ui.status.is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!(r, Err(app.ui.status.clone())); + } } diff --git a/crates/ui-egui/src/live_retouch_tests.rs b/crates/ui-egui/src/live_retouch_tests.rs new file mode 100644 index 0000000000..dff475cde4 --- /dev/null +++ b/crates/ui-egui/src/live_retouch_tests.rs @@ -0,0 +1,145 @@ +//! Pattern Stamp and Background Eraser strokes draw live (TOOL-213-7, #3754): the canvas shows +//! the engine's real result while dragging, the release commits exactly those pixels as one +//! History step, and the commit is what the one-shot command paints for the same stroke. The Spot +//! Healing Brush shows the area it will heal and heals on release, as Photoshop does. + +use egui::Modifiers; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, display_doc, strokes_live, tool_event}; +use crate::state::Tool; + +const STROKE: [(f64, f64); 4] = [(12.0, 20.0), (30.0, 24.0), (48.0, 18.0), (66.0, 22.0)]; + +/// An 80×40 document at `depth` bits: a white Background with a black block on its left half, +/// with `tool` picked (14 px, soft, no smoothing). +fn app(depth: u32, tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 80, "height": 40, "depth": depth})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#000000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 14, "hardness": 0.5, "pressureSize": false, "smoothing": {"amount": 0.0}}})).unwrap(); + app +} + +/// The active layer's pixels (or its mask) in `doc`. +fn surface(doc: &photocraft_doc::Document, app: &PhotocraftApp, mask: bool) -> Vec { + let l = doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap(); + let s = if mask { &l.mask.as_ref().unwrap().surface } else { l.surface().unwrap() }; + s.read_region(photocraft_geom::Rect::new(0, 0, 80, 40)) +} + +/// Drag `STROKE` with the picked tool. Returns what the canvas showed at the last move (the +/// targeted surface) and the params the release committed. +fn live_drag(app: &mut PhotocraftApp, cmd: &str, mask: bool) -> (Vec, Value) { + let (before, steps) = (app.session.active().unwrap().doc.clone(), app.session.active().unwrap().history.past_len()); + let m = Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: STROKE[0].0, y: STROKE[0].1, pressure: 1.0 }, m); + for &(x, y) in &STROKE[1..] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + assert!(app.live_stroke.is_some(), "{cmd}: the canvas shows the stroke while dragging"); + let shown = display_doc(app, 0).0; + let shown = surface(&shown, app, mask); + assert!(shown != surface(&before, app, mask), "{cmd}: the preview paints as it goes"); + let st = app.session.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &st.doc) && st.history.past_len() == steps, "{cmd}: nothing committed while dragging"); + let (x, y) = STROKE[3]; + tool_event(app, ToolEvent::Up { x, y }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert!(app.live_stroke.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "{cmd}: one History step"); + let params = app.session.journal.iter().rev().find(|(name, _)| name == cmd).unwrap().1.clone(); + (shown, params) +} + +/// The release committed what the drag showed, and the one-shot command gives the same pixels +/// again; without the live stroke's seed too where nothing is jittered (`keep_seed` false: a +/// brush without jitter paints the same whatever the seed; Impressionist jitters every dab). +fn check_commit(app: &mut PhotocraftApp, cmd: &str, shown: &[f32], mut params: Value, mask: bool, keep_seed: bool) { + let committed = app.session.active().unwrap().doc.clone(); + assert!(surface(&committed, app, mask) == shown, "{cmd}: the release commits the pixels the drag showed"); + app.run("edit.undo", json!({})).unwrap(); + if !keep_seed { + params.as_object_mut().unwrap().remove("seed"); + } + app.run(cmd, params).unwrap(); + assert!(surface(&app.session.active().unwrap().doc, app, mask) == shown, "{cmd}: the one-shot command paints the same"); +} + +#[test] +fn pattern_stamp_draws_live_and_commits_what_it_showed() { + for depth in [8, 16] { + for impressionist in [false, true] { + let mut app = app(depth, Tool::PatternStamp); + assert!(strokes_live(Tool::PatternStamp)); + app.ui.tool_options.pattern_stamp_impressionist = impressionist; + app.ui.tool_options.pattern_stamp_scale = 150.0; + let (shown, params) = live_drag(&mut app, "paint.patternStamp", false); + assert_eq!(params["impressionist"], json!(impressionist)); + check_commit(&mut app, "paint.patternStamp", &shown, params, false, impressionist); + } + } +} + +#[test] +fn a_live_pattern_stamp_on_the_layer_mask_paints_the_mask() { + let mut app = app(8, Tool::PatternStamp); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + let pixels = surface(&app.session.active().unwrap().doc, &app, false); + let (shown, params) = live_drag(&mut app, "paint.patternStamp", true); + assert_eq!(params["target"], json!("mask")); + assert!(surface(&app.session.active().unwrap().doc, &app, false) == pixels, "the layer's pixels are untouched"); + check_commit(&mut app, "paint.patternStamp", &shown, params, true, false); +} + +#[test] +fn background_eraser_draws_live_and_commits_what_it_showed() { + for depth in [8, 16] { + for sampling in ["continuous", "once"] { + let mut app = app(depth, Tool::BackgroundEraser); + assert!(strokes_live(Tool::BackgroundEraser)); + app.ui.tool_options.bg_sampling = sampling.into(); + let (shown, params) = live_drag(&mut app, "paint.backgroundEraser", false); + // The release turned the Background into a normal layer, as the command does. + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layer(st.active_layer.unwrap()).unwrap().name, "Layer 0"); + check_commit(&mut app, "paint.backgroundEraser", &shown, params, false, false); + } + } +} + +#[test] +fn a_refused_background_eraser_stroke_shows_nothing_and_reports_on_release() { + let mut app = app(8, Tool::BackgroundEraser); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 40.0, y: 20.0, pressure: 1.0 }, m); + assert!(app.live_stroke.is_none(), "the engine refuses the stroke, so nothing is drawn live"); + tool_event(&mut app, ToolEvent::Up { x: 40.0, y: 20.0 }, m); + assert!(app.ui.status_error && app.ui.status.contains("transparency"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +#[test] +fn spot_healing_shows_the_area_it_heals_and_heals_on_release() { + let mut app = app(8, Tool::SpotHealing); + assert!(!strokes_live(Tool::SpotHealing), "the heal needs the whole stroke"); + assert_eq!(crate::canvas::SPOT_HEALING_TRAIL.r(), 0, "a dark overlay, as Photoshop's"); + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 20.0, pressure: 1.0 }, m); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 20.0 }, m); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); +} diff --git a/crates/ui-egui/src/magnetic_lasso_ui.rs b/crates/ui-egui/src/magnetic_lasso_ui.rs index 90655368f3..28ef343212 100644 --- a/crates/ui-egui/src/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/magnetic_lasso_ui.rs @@ -420,7 +420,8 @@ pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, hover: } let screen = |q: &[f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let pts: Vec = m.path.iter().chain(m.live.iter().skip(1)).map(screen).collect(); - crate::tool_feedback::draw_ants(painter, &pts, false); + // Solid until the selection is made, as in Photoshop (#7495). + crate::tool_feedback::draw_solid_path(painter, &pts, false); for p in m.anchors.iter().filter_map(|&i| m.path.get(i)).map(screen) { let r = Rect::from_center_size(p, vec2(5.0, 5.0)); painter.rect_filled(r, 0.0, Color32::WHITE); diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 321a31e73a..9cff6a25b9 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -2,7 +2,7 @@ //! ⌥ draws from the centre (both together work too), a "W × H px" readout follows the cursor, and //! modifiers held before the drag still pick add/subtract (#188) instead of constraining. -use egui::{Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui::{Modifiers, PointerButton, Pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use photocraft_geom::Rect; @@ -13,12 +13,16 @@ use crate::canvas::ViewXform; use crate::state::Tool; fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + harness_with_step_dt(tool, 0.25) +} + +fn harness_with_step_dt(tool: Tool, step_dt: f32) -> Harness<'static, PhotocraftApp> { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); app.sync_views(); app.ui.extras.rulers = false; app.ui.tool = tool; - let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(step_dt).build_ui_state( |ui, app: &mut PhotocraftApp| { let ctx = ui.ctx().clone(); if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -147,6 +151,57 @@ fn shift_during_the_drag_draws_a_square_and_the_readout_follows() { assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag"); } +#[test] +fn object_selection_keeps_the_mode_from_the_mouse_press() { + for dragged in [false, true] { + for held in [false, true] { + for at_press in [Modifiers::SHIFT, Modifiers::ALT] { + let mut h = harness_with_step_dt(Tool::ObjectSelection, 1.0 / 60.0); + h.state_mut().ui.extras.snap = false; + h.state_mut().run("paint.pencil", json!({"points": [[220, 150]], "size": 60, "color": "#ff0000"})).unwrap(); + let st = h.state().session.active().unwrap(); + let doc = st.doc.clone(); + let target = st.active_layer; + let region = photocraft_engine::smartselect_cmds::find_object(&doc, target, false, 220.0, 150.0).expect("the red disc is detectable"); + h.state_mut().session.object_finder.insert(&doc, target, std::sync::Arc::new(region)); + if at_press.alt { + h.state_mut().run("select.all", json!({})).unwrap(); + } else { + h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + } + let start_doc = if dragged { [180.0, 110.0] } else { [220.0, 150.0] }; + mods(&mut h, at_press); + move_to(&mut h, start_doc[0], start_doc[1]); + let start = screen(&h, start_doc[0], start_doc[1]); + button(&mut h, start, true, at_press); + assert!(h.state().drag.is_none(), "the press is still below egui's drag threshold"); + let during = if held { at_press } else { Modifiers::NONE }; + mods(&mut h, during); + let end_doc = if dragged { [260.0, 190.0] } else { start_doc }; + release_at(&mut h, end_doc[0], end_doc[1], during); + let app = h.state(); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (command, params) = app.session.journal.last().expect("the gesture dispatched a command"); + assert_eq!(command, "select.object", "the canvas dispatched Object Selection"); + let expected = if at_press.alt { "subtract" } else { "add" }; + assert_eq!(params["mode"], expected, "dragged={dragged}, held={held}"); + let sel = app.session.active().unwrap().doc.selection.as_ref().expect("the old selection survives outside the object"); + assert!(sel.sample_channel(20, 20, 0) > 0.99, "the gesture must preserve selection outside the object"); + assert_eq!(sel.sample_channel(220, 150, 0) > 0.5, !at_press.alt, "the disc follows the requested mode"); + mods(&mut h, Modifiers::NONE); + move_to(&mut h, 220.0, 150.0); + let next = screen(&h, 220.0, 150.0); + button(&mut h, next, true, Modifiers::NONE); + release_at(&mut h, 220.0, 150.0, Modifiers::NONE); + let (_, params) = h.state().session.journal.last().unwrap(); + assert_eq!(params["mode"], "replace", "the next unmodified click must not reuse the previous mode"); + let sel = h.state().session.active().unwrap().doc.selection.as_ref().unwrap(); + assert!(sel.sample_channel(20, 20, 0) < 0.01 && sel.sample_channel(220, 150, 0) > 0.99); + } + } + } +} + /// #5472: a ⇧ square pressed between pixels read 101 × 102 and selected that, because each axis /// snapped to the pixel grid on its own. The readout and the selection are square, and the /// Elliptical Marquee's circle too. @@ -164,6 +219,107 @@ fn shift_square_pressed_between_pixels_has_equal_sides() { } } +/// #6015: Fixed Size pressed between pixels selected one pixel more on each side (10 × 10 became +/// 11 × 11, 1 × 1 became 2 × 2). The readout and the selection are the entered size, and a click +/// without a drag places it too, as in Photoshop. +#[test] +fn fixed_size_selects_exactly_the_entered_size() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for (w, ht) in [(10, 10), (1, 1), (64, 32)] { + let mut h = harness(tool); + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (w as f32, ht as f32); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + assert!(readout(&h, w, ht), "{tool:?} {w} × {ht}: the readout shows the entered size"); + release_at(&mut h, 90.0, 90.0, Modifiers::NONE); + assert_eq!(selection(&h), Rect::new(90, 90, 90 + w, 90 + ht), "{tool:?} {w} × {ht}: dragged to the release point"); + + // A click: the button goes down and up at one point. + h.state_mut().run("select.deselect", json!({})).unwrap(); + let p = screen(&h, 120.6, 100.4); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + button(&mut h, p, true, Modifiers::NONE); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(121, 100, 121 + w, 100 + ht), "{tool:?} {w} × {ht}: clicked"); + } + } +} + +/// Fixed Size places a box rather than sizing it: the pointer positions it in every direction, +/// including crossing the press point, without flipping by the full width or height. +#[test] +fn fixed_size_drag_positions_the_box_in_every_direction() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + for zoom in [1.0, 16.0] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].center = [60.0, 70.0]; + h.run_steps(2); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + for (x, y) in [(58.4, 66.6), (49.4, 59.6), (49.4, 66.6), (58.4, 59.6), (59.4, 60.6)] { + move_to(&mut h, x, y); + let app = h.state(); + let r = crate::canvas::marquee_preview_px(&app.ui.tool_options, app.drag.as_ref().unwrap(), zoom).unwrap(); + let (x, y) = (f64::from(x).round(), f64::from(y).round()); + assert_eq!(r, [x, y, x + 11.0, y + 11.0], "{tool:?} at zoom {zoom}: follows the pointer"); + assert!(readout(&h, 11, 11), "the live dimensions stay 11 × 11"); + } + // Commit the release position even if there was no move frame at this point. + let p = screen(&h, 60.4, 61.6); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(selection(&h), Rect::new(60, 62, 71, 73), "{tool:?}: release commits the final position"); + } + } +} + +#[test] +fn fixed_size_drag_inside_a_completed_box_still_moves_its_outline() { + for snap in [false, true] { + for zoom in [1.0, 26.730814] { + let mut h = harness(Tool::RectMarquee); + h.state_mut().ui.extras.snap = snap; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].center = [112.0, 110.0]; + h.run_steps(2); + press_at(&mut h, 100.4, 100.6, Modifiers::NONE); + release_at(&mut h, 108.4, 106.6, Modifiers::NONE); + let r = selection(&h); + let (x, y) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0); + press_at(&mut h, x, y, Modifiers::NONE); + release_at(&mut h, x + 2.0, y + 1.0, Modifiers::NONE); + assert_eq!(selection(&h), r.translate(2, 1), "zoom {zoom}, snap {snap}"); + } + } +} + +#[test] +fn fixed_size_alt_centers_the_box_at_the_current_pointer() { + for tool in [Tool::RectMarquee, Tool::EllipseMarquee] { + let mut h = harness(tool); + h.state_mut().ui.extras.snap = false; + let o = &mut h.state_mut().ui.tool_options; + o.marquee_style = "fixedSize".into(); + (o.marquee_width, o.marquee_height) = (11.0, 11.0); + press_at(&mut h, 50.4, 60.6, Modifiers::NONE); + mods(&mut h, Modifiers::ALT); + move_to(&mut h, 100.4, 100.6); + assert!(readout(&h, 11, 11)); + release_at(&mut h, 110.4, 90.6, Modifiers::ALT); + assert_eq!(selection(&h), Rect::new(105, 85, 116, 96), "{tool:?}: Alt centres the moving box"); + } +} + /// #204: Preferences › Tools › Show Transformation Values off hides the readout, not the marquee. #[test] fn show_transformation_values_off_hides_the_readout() { @@ -282,8 +438,7 @@ fn the_live_outline_matches_the_committed_selection_in_a_rotated_view() { if tool == Tool::RectMarquee { // Each corner is a corner of the dragged rectangle, mapped: an axis-aligned box // through the two diagonal corners puts all four elsewhere. - let snap = |p: Pos2| pos2(p.x.round() + 0.5, p.y.round() + 0.5); - let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| snap(xf.to_screen(x as f32, y as f32))); + let want = [[live[0], live[1]], [live[2], live[1]], [live[2], live[3]], [live[0], live[3]]].map(|[x, y]| xf.to_screen(x as f32, y as f32)); for (i, (p, w)) in pts.iter().zip(want).enumerate() { assert!((p.x - w.x).abs() < 0.01 && (p.y - w.y).abs() < 0.01, "{tool:?}: corner {i} at {p:?}, the dragged rectangle's corner is at {w:?}"); } diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 26c3791d5a..9c74a8a18f 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -1,6 +1,6 @@ //! Non-destructive layer-mask controls (pixel and vector), shared by docked and floating Properties. -use photocraft_doc::Layer; +use photocraft_doc::{Layer, LayerContent}; use serde_json::json; use crate::props_layout::COL_GAP; @@ -49,6 +49,23 @@ pub fn vector_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Lay }); } +/// A shape layer's path is its vector mask (Photoshop's Properties › Masks): its Density and +/// Feather soften the fill through `layer.vectorMask.edit`, the shape staying editable. +pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { + let LayerContent::Shape(sh) = &layer.content else { return }; + if layer.vector_mask.is_some() { + return; + } + let Some(document) = app.session.active().map(|s| s.doc.id.0) else { return }; + ui.push_id((document, layer.id, "shape-mask-properties"), |ui| { + if !crate::props_layout::section(ui, "shape-mask", "Vector Mask") { + return; + } + let (density, feather) = (sh.density, sh.feather); + sliders(app, ui, layer.id.0, document, "layer.vectorMask.edit", "shape-mask", density, feather); + }); +} + /// Density (%) and Feather (px) sliders writing one undoable edit per drag: the value lives on the /// mask, not in its pixels. `command` is the mask's edit command and `prefix` keys its coalescing. #[allow(clippy::too_many_arguments)] diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 660b94a155..782337ed36 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -1,5 +1,8 @@ use super::*; -use egui_kittest::{Harness, kittest::Queryable}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; fn harness(adjustment: bool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { let mut session = photocraft_engine::Session::new(); @@ -174,6 +177,75 @@ fn vector_mask_properties_edit_density_and_feather() { assert!((vm.density - 0.5).abs() < 0.02, "density edited through the vector mask command: {}", vm.density); } +/// #5846: a selected shape layer shows its path's Density and Feather (Photoshop's Properties › +/// Masks), in both themes; a drag previews live and leaves one undo step per gesture, and the +/// shape stays a shape. +#[test] +fn shape_properties_edit_density_and_feather() { + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let id = session.execute("shape.create", json!({"kind": "rect", "rect": [16, 16, 32, 32]})).unwrap()["layer"].as_u64().unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.panels.properties = true; + app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 1600.0)); + let mut h = Harness::builder().with_size(vec2(800.0, 1600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + if Tokens::get(ui.ctx()).pro { + egui::ScrollArea::vertical().show(ui, |ui| properties_body(app, ui)); + } else { + properties_window(app, ui.ctx()); + } + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let shape = |h: &Harness<'_, PhotocraftApp>| match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape: {}", other.kind_name()), + }; + let slider = |h: &Harness<'_, PhotocraftApp>, label: &str| { + h.query_all_by_role(egui::accesskit::Role::Slider) + .position(|n| n.accesskit_node().label().as_deref() == Some(label)) + .unwrap_or_else(|| panic!("{theme:?}: no {label} slider")) + }; + let (density, feather) = (slider(&h, "Density"), slider(&h, "Feather")); + let before = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, density, &[1.0, 0.8, 0.6, 0.5]); + assert!((shape(&h).density - 0.5).abs() < 0.02, "{theme:?}: density {}", shape(&h).density); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 1, "{theme:?}: one step per drag"); + // Live: the fill already shows beyond the path at half strength. + let a = shape(&h).cache.as_ref().unwrap().rgba(2, 2)[3]; + assert!((a - 0.5).abs() < 0.03, "{theme:?}: {a}"); + // Feather's track is curved (#6545): a fifth of the way is 8 px. + drag(&mut h, feather, &[0.0, 0.1, 0.2]); + assert!((shape(&h).feather - 8.0).abs() < 0.3, "{theme:?}: feather {}", shape(&h).feather); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(&h).feather, 0.0); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(&h).density, 1.0, "{theme:?}"); + } +} + +/// #5846: Properties › Transform (X, Y, W, H) frames a soft shape by its path, as its transform +/// frame does, though its Density renders it over the whole canvas. +#[test] +fn a_soft_shapes_transform_fields_follow_its_path() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let id = session.execute("shape.create", json!({"kind": "rect", "rect": [16, 8, 20, 30]})).unwrap()["layer"].as_u64().unwrap(); + session.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 20})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let layer = app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone(); + let surface = layer.surface().unwrap().clone(); + assert_eq!(surface.content_bounds(), photocraft_geom::Rect::new(0, 0, 64, 64), "the soft fill covers the canvas"); + assert_eq!(app.layer_frame_bounds(&layer, &surface), photocraft_geom::Rect::new(16, 8, 36, 38)); +} + /// Document-wide row of mask coverage (or of the layer's own red channel) across the middle. fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec { let s = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/mask_thumb_refresh_tests.rs b/crates/ui-egui/src/mask_thumb_refresh_tests.rs index 4987501ad0..3f486708e0 100644 --- a/crates/ui-egui/src/mask_thumb_refresh_tests.rs +++ b/crates/ui-egui/src/mask_thumb_refresh_tests.rs @@ -45,5 +45,5 @@ fn inverting_a_tileless_mask_refreshes_its_thumbnail() { assert_eq!(mask.surface.default_pixel(), vec![0.0], "and now hides everything"); let after = h.state().thumbs.get(&key).map(|(r, _)| *r).expect("mask thumbnail drawn"); assert_ne!(before, after, "the thumbnail was rebuilt after Invert"); - assert_eq!(after, surface_fingerprint(&mask.surface) ^ 200u64 << 40, "keyed on the inverted mask"); + assert_eq!(after, surface_fingerprint(&mask.surface) ^ 200u64 << 40 ^ 100u64 << 16, "keyed on the inverted mask and the document size"); } diff --git a/crates/ui-egui/src/mask_thumbs_ui.rs b/crates/ui-egui/src/mask_thumbs_ui.rs index 20f0b965d4..9e2d8e8210 100644 --- a/crates/ui-egui/src/mask_thumbs_ui.rs +++ b/crates/ui-egui/src/mask_thumbs_ui.rs @@ -22,6 +22,7 @@ pub const CHAIN_W: f32 = 12.0; pub const THUMB_LAYER: u8 = 0; pub const THUMB_MASK: u8 = 1; pub const THUMB_VECTOR: u8 = 2; +pub const THUMB_SMART_MASK: u8 = 3; /// Which of a layer's masks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -150,6 +151,12 @@ pub fn paint( painter.line_segment([k.right_top(), k.left_bottom()], st); } } + if app.ui.layers_panel_options.show_layer_mask_badges { + // Distinguish pixel and vector masks without altering their thumbnails. + let badge = Rect::from_min_size(pos2(r.right() - 9.0, r.bottom() - 9.0), vec2(9.0, 9.0)); + painter.rect_filled(badge, 1.0, t.card); + crate::icons::paint(ui, badge, if kind == MaskKind::Vector { "pen-tool" } else { "circle-dot" }, 8.0, t.text); + } painter.rect_stroke( r, if t.pro { 0.0 } else { 4.0 }, @@ -166,6 +173,7 @@ pub fn paint( type Rects = (Vec<(MaskKind, Rect)>, Vec<(MaskKind, Rect)>); +/// Memory key of the per-layer record of where the mask thumbnails and chains were drawn. fn rects_id() -> egui::Id { egui::Id::new("mask-thumb-rects") } @@ -175,6 +183,12 @@ pub fn recorded(ctx: &egui::Context, id: u64) -> Option { ctx.data(|d| d.get_temp::>(rects_id())).and_then(|m| m.get(&id).cloned()) } +/// Drop what was recorded for layer `id`, so a row drawn without mask thumbnails (thumbnail size +/// None) can't be hit through rects from an earlier frame. +pub fn forget(ctx: &egui::Context, id: u64) { + ctx.data_mut(|d| d.get_temp_mut_or_default::>(rects_id()).remove(&id)); +} + /// A small vertical chain: two interlocking links. fn paint_chain(painter: &Painter, c: Pos2, color: Color32) { let st = Stroke::new(1.2, color); diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 163c7cbae8..230d0add3a 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -214,6 +214,36 @@ fn dragging_a_layer_row_to_trash_still_deletes_the_layer_with_a_mask_targeted() assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none()); } +/// With the thumbnail size set to None no mask thumbnail is drawn, so a drag from where one used +/// to be moves the layer (the row) rather than a mask that is no longer on screen. +#[test] +fn thumbnails_off_forget_the_mask_rects_and_drag_the_layer() { + let (s, masked, _) = session(); + let mut h = harness(s, 0, 1.0, 290.0); + let stale = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == MaskKind::Pixel).unwrap().1; + click_with(&mut h, stale.center(), Modifiers::NONE); + h.state_mut().ui.layers_panel_options.thumbnail_size = crate::state::LayerThumbnailSize::None; + h.run_steps(3); + assert!(recorded(&h.ctx, masked).is_none(), "no mask thumbnails are recorded while they are off"); + + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == masked).unwrap().row; + let from = pos2(stale.center().x, row.center().y); + assert!(row.contains(from), "the old thumbnail position is still on the row"); + let to = h.get_by_label("Delete Layer Mask").rect().center(); + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for k in 1..=8 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(!h.state().ui.dialogs.iter().any(|d| crate::mask_trash_prompt::owns(&d.fields)), "no mask prompt: the mask was not dragged"); + assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none(), "the layer went to the trash"); +} + /// #3113: dragging the layer mask's thumbnail onto the Trash asks "Apply mask to layer before /// removing?" and keeps the layer; Apply applies the mask, Delete deletes it. #[test] @@ -451,7 +481,8 @@ fn mask_view(h: &Harness<'_, PhotocraftApp>) -> serde_json::Value { fn canvas_px(h: &Harness<'_, PhotocraftApp>) -> Option> { let st = h.state().session.active().unwrap(); - crate::channel_view::render(&st.doc, &st.channel_view, photocraft_geom::Rect::new(0, 0, 200, 100), 1, false, st.active_layer) + let look = crate::channel_view::Look::of(&h.state().session); + crate::channel_view::render(&st.doc, &st.channel_view, photocraft_geom::Rect::new(0, 0, 200, 100), 1, look, st.active_layer) } fn near(a: Rect, b: Rect) -> bool { @@ -621,6 +652,9 @@ fn backslash_toggles_the_rubylith() { assert_eq!(crate::canvas::paint_target(h.state()), json!("mask"), "painting paints the mask"); let px = canvas_px(&h).unwrap(); assert_eq!(px[50 * 200 + 50], egui::Color32::from_rgba_premultiplied(128, 0, 0, 128), "50% red over hidden areas"); + // Layer Mask Display Options: opaque green over what the mask hides instead. + h.state_mut().run("channel.options", json!({"channel": "mask", "color": "#00ff00", "opacity": 100})).unwrap(); + assert_eq!(canvas_px(&h).unwrap()[50 * 200 + 50], egui::Color32::from_rgb(0, 255, 0)); h.key_press(egui::Key::Backslash); h.run_steps(2); assert_eq!(mask_view(&h), serde_json::Value::Null, "\\ again hides it"); diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index c1536d8ee6..83d8d2dbbe 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -8,7 +8,8 @@ //! They are assigned per menu level from the labels as shown (so translated), unique within //! the level: Photoshop's letters for the top menus where the label has them (F E I L Y S T V //! W H), else the first unused letter, word starts first. Only ASCII letters and digits are keys -//! the keyboard reports as such, so a label without one (Japanese, Chinese…) has no mnemonic. +//! the keyboard reports as such, so a label without one (Japanese, Chinese…) shows its English +//! label's letter after it, as Windows menus do in those languages: "文件(F)" ([`localized`]). //! //! The state is view state in egui memory, like [`crate::menu_nav::Nav`]. On macOS the native //! menu bar replaces the in-window one and this never runs. @@ -42,6 +43,9 @@ pub struct Keys { alt_down: bool, /// Alt went down and nothing else was pressed since: releasing it is a tap. alt_tap: bool, + /// The mouse buttons that are down, one bit each, from the button events in order (the + /// frame's pointer state is its end state, which a whole click within one frame doesn't show). + buttons_held: u8, /// A menu was just opened from the keyboard: light its first row once it is drawn. first_row: bool, } @@ -61,6 +65,40 @@ pub fn is_focused(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(focus_id())).is_some_and(|seen| seen + 2 >= pass) } +/// A command shortcut pressed while the keyboard is on the bar with no menu open (⌘D, ⌘⇧I, F7…) +/// takes it off the bar, so the shortcut runs in the same press, as a native menu bar does +/// (#6573). Keys the bar uses (arrows, ↩, Esc, letters, Alt+letter, F10) keep driving it, and +/// an open menu still swallows shortcuts. True when the bar was left. +pub fn leave_for_shortcut(ctx: &egui::Context) -> bool { + if !is_focused(ctx) || crate::menu_nav::is_open(ctx) { + return false; + } + let shortcut = ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if is_command_key(*key, *modifiers)))); + if !shortcut { + return false; + } + let mut keys = Keys::load(ctx); + keys.focus = None; + keys.underline = false; + keys.store(ctx); + ctx.request_repaint(); + true +} + +/// A key press that is a command shortcut rather than a key of the bar: ⌘ / Ctrl with a key +/// (not the modifier keys themselves), or a function key other than F10 (which toggles the bar). +fn is_command_key(key: Key, mods: Modifiers) -> bool { + let modifier_key = matches!( + key, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ); + if modifier_key { + return false; + } + let function_key = key != Key::F10 && key.name().strip_prefix('F').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + mods.command || mods.ctrl || mods.mac_cmd || function_key +} + /// The mnemonic a key press stands for: the letter or digit of a plain key. fn key_char(key: Key) -> Option { let name = key.name(); @@ -94,6 +132,49 @@ pub fn assign(labels: &[&str], preferred: &[Option]) -> Vec> out } +/// The labels to show and their mnemonics for translated `labels` whose English originals are +/// `english` (same order). A label with an ASCII letter or digit keeps its text and is assigned +/// as [`assign`] does. One without (Chinese, Japanese, Korean…) can't be typed on its own, so it +/// shows its English label's letter after it in parentheses and takes that letter, as Windows menus +/// do in those languages ("文件(F)", #5863). Mnemonics stay unique within the group, and a shown +/// "(F)" is always the row's key. +pub fn localized(labels: &[String], english: &[&str], preferred: &[Option]) -> (Vec, Vec>) { + let typed = |l: &str| l.chars().any(|c| c.is_ascii_alphanumeric()); + // The key goes before a trailing ellipsis, as Windows writes it: "新建(N)…". + let with_key = |l: &str, k: char| match l.strip_suffix('…').map(|b| (b, "…")).or_else(|| l.strip_suffix("...").map(|b| (b, "..."))) { + Some((base, tail)) => format!("{base}({k}){tail}"), + None => format!("{l}({k})"), + }; + let english_keys = assign(english, preferred); + let mut shown = Vec::with_capacity(labels.len()); + let mut pref = Vec::with_capacity(labels.len()); + for (i, label) in labels.iter().enumerate() { + match english_keys.get(i).copied().flatten().filter(|_| !typed(label)) { + Some(k) => { + shown.push(with_key(label, k)); + pref.push(Some(k)); + } + None => { + shown.push(label.clone()); + pref.push(preferred.get(i).copied().flatten()); + } + } + } + // The suffixed labels take their keys first: a label with letters of its own must not take + // the key a suffix shows ("Camera Raw 滤镜…" before "转换为智能滤镜(C)" leaves C to the latter). + let suffixed = |i: &usize| shown.get(*i) != labels.get(*i); + let order: Vec = (0..shown.len()).filter(suffixed).chain((0..shown.len()).filter(|i| !suffixed(i))).collect(); + let ordered: Vec<&str> = order.iter().map(|&i| shown.get(i).map_or("", String::as_str)).collect(); + let ordered_pref: Vec> = order.iter().map(|&i| pref.get(i).copied().flatten()).collect(); + let mut keys = vec![None; shown.len()]; + for (&i, key) in order.iter().zip(assign(&ordered, &ordered_pref)) { + if let Some(slot) = keys.get_mut(i) { + *slot = key; + } + } + (shown, keys) +} + /// `prefix + label` as menu text with the mnemonic's first occurrence in `label` underlined /// (none when `mnemonic` is None). `color` is the text colour; None takes the widget's. pub fn mnemonic_text(ui: &Ui, prefix: &str, label: &str, mnemonic: Option, color: Option) -> egui::WidgetText { @@ -208,22 +289,43 @@ impl Keys { } let events = ctx.input(|i| i.events.clone()); let mut tap = false; - for e in &events { + // The tap is judged on every event of the frame, also those another handler consumed + // before the bar's turn: the shortcuts run first, so Ctrl+Alt+I (Image Size) left no + // trace in `events`, and releasing Alt counted as a tap that put the keyboard on the bar; + // Enter then opened File instead of confirming the dialog (#7481). + let raw = ctx.input(|i| i.raw.events.clone()); + for e in &raw { match e { Event::Key { key: Key::AltLeft | Key::AltRight, pressed: true, .. } => { self.alt_down = true; - self.alt_tap = true; + // Alt pressed while a mouse button is down is a modifier for that drag + // (Free Transform's scale-from-centre), never a tap (#8113). + self.alt_tap = self.buttons_held == 0; } Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => { self.alt_down = false; tap |= std::mem::take(&mut self.alt_tap); } - // Alt+wheel (zoom, brush size…) or Alt going to the desktop is no tap either. - Event::Key { pressed: true, .. } - | Event::Text(_) - | Event::PointerButton { pressed: true, .. } - | Event::MouseWheel { .. } - | Event::WindowFocused(false) => self.alt_tap = false, + // A click or the end of a drag while Alt is held is no tap. It also cancels a tap + // taken earlier in this frame: a whole drag can arrive in one frame (press, Alt + // down, Alt up, release). + Event::PointerButton { button, pressed, .. } => { + let bit = 1u8 << (*button as u8); + if *pressed { + self.buttons_held |= bit; + } else { + self.buttons_held &= !bit; + } + self.alt_tap = false; + tap = false; + } + // Alt going to the desktop is no tap, and a button let go out there is up. + Event::WindowFocused(false) => { + self.alt_tap = false; + self.buttons_held = 0; + } + // Alt+wheel (zoom, brush size…) is no tap either. + Event::Key { pressed: true, .. } | Event::Text(_) | Event::MouseWheel { .. } => self.alt_tap = false, _ => {} } } @@ -348,6 +450,21 @@ mod tests { assert_eq!(key_char(Key::ArrowDown), None); assert_eq!(key_char(Key::Space), None); } + + #[test] + fn command_keys_are_command_combos_and_function_keys_but_f10() { + assert!(is_command_key(Key::D, Modifiers::COMMAND)); + assert!(is_command_key(Key::I, Modifiers::COMMAND | Modifiers::SHIFT)); + assert!(is_command_key(Key::F7, Modifiers::NONE)); + assert!(is_command_key(Key::F12, Modifiers::SHIFT)); + assert!(!is_command_key(Key::F10, Modifiers::NONE), "F10 toggles the bar"); + assert!(!is_command_key(Key::ControlLeft, Modifiers::CTRL), "Ctrl going down is no shortcut yet"); + assert!(!is_command_key(Key::E, Modifiers::NONE), "a letter is a mnemonic"); + assert!(!is_command_key(Key::E, Modifiers::ALT), "Alt+letter opens a menu"); + assert!(!is_command_key(Key::ArrowRight, Modifiers::NONE)); + assert!(!is_command_key(Key::Enter, Modifiers::NONE)); + assert!(!is_command_key(Key::Escape, Modifiers::NONE)); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/menu_keys_tests.rs b/crates/ui-egui/src/menu_keys_tests.rs index 0298cb470f..bf99d07cec 100644 --- a/crates/ui-egui/src/menu_keys_tests.rs +++ b/crates/ui-egui/src/menu_keys_tests.rs @@ -242,6 +242,60 @@ fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() { assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again"); } +/// #5863: a label with no Latin letter (Chinese, Japanese…) shows its English label's letter in +/// parentheses, before a trailing ellipsis, and takes it as its mnemonic; labels with letters and +/// the top menus' Photoshop letters are as before, and the letters stay unique. +#[test] +fn labels_without_latin_letters_show_their_english_key() { + let labels: Vec = ["文件", "编辑", "Image", "图层"].map(String::from).to_vec(); + let english = ["File", "Edit", "Image", "Layer"]; + let preferred = [Some('F'), Some('E'), Some('I'), Some('L')]; + let (shown, keys) = crate::menu_keys::localized(&labels, &english, &preferred); + assert_eq!(shown, ["文件(F)", "编辑(E)", "Image", "图层(L)"]); + assert_eq!(keys, [Some('F'), Some('E'), Some('I'), Some('L')]); + // Rows: the key goes before the ellipsis; a clash takes the next letter of the shown label. + let labels: Vec = ["新建…", "打开...", "关闭"].map(String::from).to_vec(); + let (shown, keys) = crate::menu_keys::localized(&labels, &["New…", "Open...", "Close"], &[]); + assert_eq!(shown, ["新建(N)…", "打开(O)...", "关闭(C)"]); + assert_eq!(keys, [Some('N'), Some('O'), Some('C')]); + let (shown, keys) = crate::menu_keys::localized(&["关闭".to_string(), "复制".to_string()], &["Close", "Copy"], &[]); + assert_eq!(shown, ["关闭(C)", "复制(O)"]); + assert_eq!(keys, [Some('C'), Some('O')]); + // A shown "(C)" keeps its key even when a label with letters of its own comes first. + let (shown, keys) = crate::menu_keys::localized(&["Copy".to_string(), "关闭".to_string()], &["Open", "Close"], &[]); + assert_eq!(shown, ["Copy", "关闭(C)"]); + assert_eq!(keys, [Some('O'), Some('C')]); + // An English UI is unchanged. + let labels: Vec = ["File", "Edit"].map(String::from).to_vec(); + assert_eq!(crate::menu_keys::localized(&labels, &["File", "Edit"], &[Some('F'), Some('E')]).0, labels); +} + +/// The whole app: with the bar focused by an Alt tap, a command shortcut leaves the bar and runs +/// in the same press (#6573); the bar's own keys still drive it. +#[test] +fn a_command_shortcut_leaves_the_bar_and_runs() { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = app(); + app.services.native_menu = None; + app + }); + h.run_steps(4); + h.state_mut().run("select.all", json!({})).unwrap(); + h.run_steps(2); + alt_tap(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "Alt puts the keyboard on File"); + key(&mut h, egui::Key::ArrowRight); + assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ still moves along the bar"); + assert!(h.state().session.active().unwrap().doc.selection.is_some()); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::D); + h.run_steps(3); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "⌘D deselects"); + assert_eq!(Keys::current(&h.ctx).focus, None, "and takes the keyboard off the bar"); + assert!(!crate::menu_keys::is_focused(&h.ctx)); + assert!(!is_open(&h.ctx), "no menu opened"); +} + /// Checked, unchecked and plain rows (and submenu headers) start their label at the same x, in /// any script: the checkmark column has a fixed width instead of padding characters (#5705). #[test] @@ -279,3 +333,110 @@ fn every_menu_row_starts_its_label_after_one_checkmark_column() { }); out.textures_delta.clear(); } + +/// The whole app (shortcuts, dialogs and the menu bar), for keys other handlers see first. +fn whole_app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(16).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app() + }); + h.run_steps(4); + h +} + +/// `key` pressed and released with `mods` held, as the platform reports a chord. +fn chord(h: &mut Harness<'static, PhotocraftApp>, key: egui::Key, mods: egui::Modifiers) { + h.event_modifiers(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }, mods); + h.event_modifiers(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: mods }, mods); + h.run_steps(2); +} + +fn alt_down(h: &mut Harness<'static, PhotocraftApp>, mods: egui::Modifiers) { + h.event_modifiers(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: mods }, mods); + h.run_steps(2); +} + +fn alt_up(h: &mut Harness<'static, PhotocraftApp>) { + h.event_modifiers( + egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }, + egui::Modifiers::NONE, + ); + h.run_steps(3); +} + +/// #7481: Alt held through a shortcut is no tap, even when the shortcut consumed its key before +/// the bar saw it. Ctrl+Alt+I opens Image Size; releasing Alt leaves the keyboard where it was, +/// so Enter confirms the dialog instead of opening File. A lone tap still puts it on the bar. +#[test] +fn alt_held_through_a_shortcut_does_not_put_the_keyboard_on_the_bar() { + let mut h = whole_app(); + alt_down(&mut h, egui::Modifiers::ALT); + chord(&mut h, egui::Key::I, egui::Modifiers::CTRL | egui::Modifiers::ALT | egui::Modifiers::COMMAND); + alt_up(&mut h); + assert_eq!(h.state().ui.dialogs.len(), 1, "Image Size opened"); + assert_eq!(Keys::current(&h.ctx).focus, None, "the bar did not take the keyboard"); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert!(!is_open(&h.ctx), "Enter opened no menu"); + assert!(h.state().ui.dialogs.is_empty(), "Enter confirmed Image Size"); + + alt_down(&mut h, egui::Modifiers::ALT); + alt_up(&mut h); + assert_eq!(Keys::current(&h.ctx).focus, Some(0), "a lone tap still focuses the bar"); +} + +/// #8113: Alt pressed during a drag (Free Transform's scale from the centre) is a modifier for the +/// drag, not a tap: with the mouse button let go before or after Alt, the keyboard stays where +/// it was, so Enter commits the transform instead of opening File. A lone tap still works. +#[test] +fn alt_held_during_a_drag_is_no_tap() { + let mut h = bar(); + let at = egui::pos2(640.0, 400.0); + let button = |h: &mut Harness<'static, PhotocraftApp>, pressed: bool, mods: egui::Modifiers| { + h.event_modifiers(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: mods }, mods); + h.run_steps(2); + }; + for mouse_up_first in [true, false] { + h.hover_at(at); + button(&mut h, true, egui::Modifiers::NONE); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + if mouse_up_first { + button(&mut h, false, egui::Modifiers::ALT); + h.key_up(egui::Key::AltLeft); + } else { + h.key_up(egui::Key::AltLeft); + h.run_steps(2); + button(&mut h, false, egui::Modifiers::NONE); + } + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "mouse up first: {mouse_up_first}"); + key(&mut h, egui::Key::Enter); + assert!(!is_open(&h.ctx), "Enter opens no menu (mouse up first: {mouse_up_first})"); + } + // The whole gesture within one frame: press, Alt down, Alt up, release. + let (alt, none) = (egui::Modifiers::ALT, egui::Modifiers::NONE); + h.event_modifiers(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: none }, none); + h.event_modifiers(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: alt }, alt); + h.event_modifiers(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: false, repeat: false, modifiers: none }, none); + h.event_modifiers(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: none }, none); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "one frame"); + // Two buttons: the right one let go while the left is still down. Alt pressed then is still + // pressed during the left-button drag. + let btn = |h: &mut Harness<'static, PhotocraftApp>, button: egui::PointerButton, pressed: bool| { + h.event_modifiers(egui::Event::PointerButton { pos: at, button, pressed, modifiers: none }, none); + h.run_steps(2); + }; + btn(&mut h, egui::PointerButton::Primary, true); + btn(&mut h, egui::PointerButton::Secondary, true); + btn(&mut h, egui::PointerButton::Secondary, false); + h.key_down(egui::Key::AltLeft); + h.run_steps(2); + h.key_up(egui::Key::AltLeft); + h.run_steps(3); + assert_eq!(Keys::current(&h.ctx).focus, None, "the left button is still down"); + btn(&mut h, egui::PointerButton::Primary, false); + alt_tap(&mut h); + assert!(Keys::current(&h.ctx).focus.is_some(), "a lone tap still focuses the bar"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 63b46f4429..57b15fb8ad 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,7 +13,7 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { if app.camera_raw.is_some() || crate::crop_ui::blocks(app, id) { return false; } - (app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) + (app.ui.dialogs.is_empty() && app.ui.layers_panel_dialog.is_none() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) } /// Top-level menus in Photoshop order. @@ -240,7 +240,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Some((true, path, d)) => { // Another app changed the file since it was opened or saved: ask first (#6596). let doc = d.doc.id; - crate::disk_change_prompt::intercept(app, doc, &path, false) + crate::disk_change_prompt::intercept(app, doc, &path, false, crate::FormatChoices::default()) .unwrap_or_else(|| app.save_in_place(path, crate::FormatChoices::default())) } Some((false, path, d)) if photocraft_engine::file_cmds::saves_in_place(&path) => { @@ -383,6 +383,18 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: crate::export_dialog::quick_export_selected_layers_png(app) } } + "layer.smartFilter.blendingOptions" if params.get("blend").is_none() && params.get("opacity").is_none() => { + let st = app.session.active().ok_or("no document open")?; + let layer = params.get("layer").and_then(Value::as_u64).map(photocraft_doc::LayerId).or(st.active_layer).ok_or("no layer selected")?; + let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { + return Err("the layer is not a Smart Object".into()); + }; + let index = match params.get("index") { + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or("invalid Smart Filter index")?, + None => sm.smart_filters.len().checked_sub(1).ok_or("the Smart Object has no Smart Filters")?, + }; + crate::smart_filter_dialog::open(app, layer, index).map(|id| json!({"dialog": id})) + } "layer.layerStyle.blendingOptions" if params.as_object().is_none_or(|o| o.is_empty()) => { crate::layer_style::open(app, Some(crate::layer_style::BLENDING)).map(|d| json!({"dialog": d})).ok_or_else(|| "no active layer".to_string()) } @@ -446,13 +458,16 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(json!({"dialog": crate::stroke_ui::open(app)})) } // Channels › Channel Options… without params: the dialog on the targeted alpha/spot - // channel, or Quick Mask Options while in Quick Mask (with params: the engine directly). + // channel, Layer Mask Display Options on a targeted layer mask, or Quick Mask Options while + // in Quick Mask (with params: the engine directly). crate::channel_options_ui::COMMAND if params.as_object().is_none_or(|o| o.is_empty()) => { + let mask = app.layer_mask_targeted(); let st = app.session.active().ok_or("no document is open")?; let channel = match st.channel_view.target { _ if st.doc.quick_mask.is_some() => json!("quickMask"), photocraft_engine::channel_cmds::ChannelTarget::Alpha(i) => json!(i), - _ => return Err("target an alpha or spot channel first".into()), + _ if mask => json!("mask"), + _ => return Err("target an alpha or spot channel, or a layer mask, first".into()), }; let dialog = crate::channel_options_ui::open(app, &channel).ok_or("no such channel")?; Ok(json!({"dialog": dialog})) @@ -587,6 +602,12 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: } "window.togglePanels" => { // Tab hides the Tools panel, the options bar and the dock; Tab again shows what it hid. + if app.ui.view.hides_chrome() { + // Full Screen Mode: Tab only shows or hides the chrome for now (#6760); the panels' + // own state, `hidden_by_tab` included, stays as it was before entering. + app.ui.panels.full_screen_chrome = !app.ui.panels.full_screen_chrome; + return Ok(Value::Null); + } let p = &mut app.ui.panels; match p.hidden_by_tab.take() { Some([toolbar, options, dock]) if !(p.toolbar || p.options_bar || p.dock) => { @@ -1000,6 +1021,7 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { QuickExportFormat::Jpg => "JPG", QuickExportFormat::Gif => "GIF", QuickExportFormat::Webp => "WebP", + QuickExportFormat::Exr => "EXR", } }; item.label = format!("Quick Export as {format}"); @@ -1037,6 +1059,22 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { items.insert(after + 1 + k, it); } } + // Window › Workspace: saved workspaces follow the presets, checked while current. + let saved = crate::workspace_ui::saved_names(app); + if !saved.is_empty() + && let Some(after) = items.iter().position(|i| i.id == "window.workspace.motion") + { + let wp: Vec = vec!["Window".into(), "Workspace".into()]; + let separator = MenuItem { id: "---".into(), label: "---".into(), path: wp.clone(), shortcut: None, enabled: false, checked: None, color: None }; + let entries = saved.into_iter().enumerate().map(|(i, name)| { + let id = format!("{}{i}", crate::workspace_ui::SAVED); + let checked = checked(app, &id); + MenuItem { id, label: name, path: wp.clone(), shortcut: None, enabled: true, checked, color: None } + }); + for (k, it) in std::iter::once(separator).chain(entries).enumerate() { + items.insert(after + 1 + k, it); + } + } // Help: the link items, a separator, then System Info and About. if let Some(at) = items.iter().position(|i| i.id == "help.systemInfo" || i.id == "help.about") { items.insert( @@ -1144,9 +1182,10 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> f32 { let mut keys = crate::menu_keys::Keys::load(ui.ctx()); keys.take_first_row(&mut nav); nav.underline = keys.show_underlines(ui.ctx()); - let top_labels: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); + let translated: Vec = TOP_MENUS.iter().map(|top| crate::i18n::tr(lang, top).to_string()).collect(); let preferred: Vec> = TOP_MENUS.iter().map(|top| crate::menu_keys::TOP_PREFERRED.iter().find(|(t, _)| t == top).map(|(_, c)| *c)).collect(); - let top_mnemonics = crate::menu_keys::assign(&top_labels.iter().map(String::as_str).collect::>(), &preferred); + // A title with no Latin letter shows its English key: "文件(F)" (#5863). + let (top_labels, top_mnemonics) = crate::menu_keys::localized(&translated, &TOP_MENUS, &preferred); ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let v = &mut ui.style_mut().visuals; @@ -1290,8 +1329,14 @@ fn released_on(ui: &egui::Ui, item: &egui::Response) -> bool { /// True only when the pointer can actually reach a menu title. A tall submenu can be /// repositioned upward by egui and overlap the menu bar; in that case the popup's layer is /// top-most and hovering it must not switch the open top-level menu. +/// +/// The title's column extends up to the top of the window: egui lights a widget up to +/// `interact_radius` away, so with the window maximized a pointer pushed against the screen edge +/// (above the title's own rect) highlights it and must switch to it too (#7702). fn pointer_reaches_title(ctx: &egui::Context, response: &egui::Response, p: egui::Pos2) -> bool { - response.interact_rect.contains(p) && ctx.layer_id_at(p) == Some(response.layer_id) + let r = response.interact_rect; + let column = egui::Rect::from_min_max(egui::pos2(r.left(), f32::NEG_INFINITY), r.max); + column.contains(p) && ctx.layer_id_at(p) == Some(response.layer_id) } /// Like a native menu bar (Windows, macOS): while one top-level menu is open, hovering another @@ -1432,24 +1477,25 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click // of its first child. // Each row's mnemonic (#4988), unique within this level, in display order: the leaves' labels // and the submenus' names as shown. - let shown_labels: Vec = { + // A label with no Latin letter shows its English label's key after it: "新建(N)…" (#5863). + let (translated, english): (Vec, Vec<&str>) = { let mut subs: Vec<&str> = Vec::new(); items .iter() .filter_map(|it| { if it.path.len() == depth { - (it.label != "---").then(|| translated_menu_label(lang, it)) + (it.label != "---").then(|| (translated_menu_label(lang, it), it.label.as_str())) } else { let name = it.path.get(depth).map(String::as_str)?; (!subs.contains(&name)).then(|| { subs.push(name); - crate::i18n::tr(lang, name).to_string() + (crate::i18n::tr(lang, name).to_string(), name) }) } }) - .collect() + .unzip() }; - let mnemonics = crate::menu_keys::assign(&shown_labels.iter().map(String::as_str).collect::>(), &[]); + let (shown_labels, mnemonics) = crate::menu_keys::localized(&translated, &english, &[]); let mut next_row = 0usize; let mut shown_subs: Vec<&str> = Vec::new(); let mut last_was_sep = true; @@ -1462,7 +1508,7 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click } continue; } - let label = translated_menu_label(lang, it); + let label = shown_labels.get(next_row).cloned().unwrap_or_else(|| translated_menu_label(lang, it)); let mnemonic = mnemonics.get(next_row).copied().flatten(); next_row += 1; // Checked, unchecked and plain rows share one checkmark column (#5705). @@ -1498,8 +1544,9 @@ fn render_level_rows(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, click let child: Vec<&MenuItem> = items.iter().copied().filter(|c| c.path.len() > depth && c.path[depth] == name).collect(); let enabled = submenu_enabled(&child); let mnemonic = mnemonics.get(next_row).copied().flatten(); + let shown = shown_labels.get(next_row).cloned().unwrap_or_else(|| crate::i18n::tr(lang, name).to_string()); next_row += 1; - let title = crate::menu_keys::menu_row_text(ui, false, crate::i18n::tr(lang, name), mnemonic.filter(|_| nav.underline)); + let title = crate::menu_keys::menu_row_text(ui, false, &shown, mnemonic.filter(|_| nav.underline)); ui.add_enabled_ui(enabled, |ui| { nav.row(ui, depth - 1, enabled, None, mnemonic, |ui, nav| { let (response, inner) = submenu_row(ui, title, |ui| render_level(ui, &child, depth + 1, clicked, nav)); @@ -1761,6 +1808,34 @@ mod tests { assert!(!reaches); } + /// #7702: a pointer at the very top edge of the screen (the window maximized) moving between + /// titles switches the open menu, as it does a little lower. + #[test] + fn hovering_a_title_at_the_screen_edge_switches_the_open_menu() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).build_ui_state( + |ui, app| { + egui::Frame::NONE.inner_margin(egui::Margin { top: 4, ..Default::default() }).show(ui, |ui| { + menu_bar(app, ui); + }); + }, + app, + ); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + harness.get_by_label("File").click(); + harness.run_steps(3); + assert!(harness.query_by_label_contains("Open…").is_some()); + let image = harness.get_by_label("Image").rect(); + assert!(image.top() > 0.0, "the title sits below the screen edge"); + harness.hover_at(egui::pos2(image.center().x, 0.0)); + harness.run_steps(4); + assert!(harness.query_by_label_contains("Open…").is_none(), "File menu should close"); + assert!(harness.query_by_label_contains("Duplicate…").is_some(), "Image menu should open from the screen edge"); + } + #[test] fn hovering_another_title_switches_the_open_menu() { use egui_kittest::{Harness, kittest::Queryable}; @@ -2140,7 +2215,7 @@ mod open_recent_tests { use photocraft_color::{ColorMode, SampleType}; use photocraft_geom::Size; let services = crate::Services { - import: Some(Box::new(|_n: &str, _b: &[u8], _depth: usize| { + import: Some(Box::new(|_n: &str, _b: &[u8], _opts: &photocraft_io::ImportOptions| { Ok((photocraft_doc::Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())) })), ..Default::default() @@ -2168,7 +2243,7 @@ mod quick_export_label_tests { #[test] fn file_quick_export_menu_reflects_selected_format_without_renaming_layer_export() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP")] { + for (value, expected) in [("png", "PNG"), ("jpg", "JPG"), ("gif", "GIF"), ("webp", "WebP"), ("exr", "EXR")] { app.run("prefs.set", json!({"values": {"export.quickExportFormat": value}})).unwrap(); let items = menu_items(&app); let quick: Vec<_> = items.iter().filter(|i| i.id == "file.export.quickExportAsPng").collect(); diff --git a/crates/ui-egui/src/move_box.rs b/crates/ui-egui/src/move_box.rs index ccbfd9cb4f..f49a5061be 100644 --- a/crates/ui-egui/src/move_box.rs +++ b/crates/ui-egui/src/move_box.rs @@ -50,6 +50,20 @@ pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { let b = LayerBox { end: [x, y], ..*b }; app.layer_box = None; let (w, h) = (b.end[0] - b.start[0], b.end[1] - b.start[1]); + if w == 0.0 && h == 0.0 && !b.add { + // A click on the pasteboard clears the layer target; a drag from there still + // uses the marquee, and a click on transparent pixels inside the canvas does not. + if let Some(st) = app.session.active() { + let rect = st.doc.bounds(); + let outside = b.start[0] < f64::from(rect.x0) + || b.start[1] < f64::from(rect.y0) + || b.start[0] >= f64::from(rect.x1) + || b.start[1] >= f64::from(rect.y1); + if outside { + let _ = app.run("select.deselectLayers", json!({})); + } + } + } if w == 0.0 || h == 0.0 { return true; } @@ -115,6 +129,31 @@ mod tests { assert_eq!(sel, ids); } + /// #4923: clicking the pasteboard, but not empty canvas pixels, clears layer targets. + /// A Shift-click preserves the selection, and a pasteboard drag remains a marquee. + #[test] + fn pasteboard_click_deselects_layers_without_affecting_empty_canvas_clicks() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + let layer = app.session.active().unwrap().active_layer.unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![layer]); + let click = |app: &mut PhotocraftApp, p: [f64; 2], mods| { + tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, mods); + }; + click(&mut app, [30.0, 30.0], egui::Modifiers::NONE); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![layer], "empty pixels are still in the canvas"); + click(&mut app, [-10.0, 30.0], egui::Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![layer], "Shift adds, not clears"); + click(&mut app, [-10.0, 30.0], egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert!(st.selected_layers().is_empty() && st.active_layer.is_none()); + assert!(app.layer_box.is_none()); + } + /// #4058: a drag that starts on the locked Background draws the layer marquee. It does not /// open the move-lock dialog, and releasing selects the unlocked layer the box touches. #[test] diff --git a/crates/ui-egui/src/move_distance.rs b/crates/ui-egui/src/move_distance.rs new file mode 100644 index 0000000000..a5603590c4 --- /dev/null +++ b/crates/ui-egui/src/move_distance.rs @@ -0,0 +1,391 @@ +//! Smart Guides › measuring distances, as in Photoshop (#4946): with Smart Guides on (View › Show +//! › Smart Guides, Extras on) and the Move tool, holding ⌘ (Ctrl off the Mac) with no button down +//! and hovering another layer shows the distances between the selected layers' bounds and that +//! layer's, as magenta lines labelled with the value in the ruler unit. Over empty canvas (or the +//! Background, or the selection itself) the distances run to the canvas edges instead. While an +//! ⌥-drag duplicates the layers, the distance between the original and the copy shows. +//! +//! It's an overlay only: nothing changes in the document and there is no command or History +//! state. The hovered layer is the one a click there would Auto-Select +//! (`pick_cmds::auto_select_target`), cached per document revision, selection and pointer pixel, +//! so a pointer resting on the canvas costs nothing per frame. + +use egui::{Color32, Pos2, Stroke, pos2, vec2}; +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_engine::snap::{layer_rect, union}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// One measured distance: a line along x (`horizontal`, at y = `at`) or along y (at x = `at`), +/// from `from` to `to` on that axis (`from < to`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Measure { + pub horizontal: bool, + pub at: f64, + pub from: f64, + pub to: f64, +} + +impl Measure { + pub fn len(&self) -> f64 { + self.to - self.from + } +} + +/// What to draw between two boxes: the measured lines, and dashed extensions `[x0, y0, x1, y1]` +/// of the other box's edges where a line ends beside the box rather than on it. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Overlay { + pub measures: Vec, + pub extensions: Vec<[f64; 4]>, +} + +/// The distances from box `a` (the selection, `[x0, y0, x1, y1]`) to box `b` (the hovered layer +/// or the canvas), per axis: the gap between facing edges when the boxes are apart on that axis, +/// else the distances between their corresponding edges (left to left, right to right). Zero +/// distances aren't shown. A line runs through the middle of where the boxes overlap on the other +/// axis, or through `a`'s centre when they don't. +pub(crate) fn measure(a: [f64; 4], b: [f64; 4]) -> Overlay { + let mut out = Overlay::default(); + if !a.iter().chain(b.iter()).all(|v| v.is_finite()) { + return out; + } + for horizontal in [true, false] { + // Spans along the measured axis (`s`) and across it (`o`). + let (k, o) = if horizontal { (0, 1) } else { (1, 0) }; + let (sa, sb) = ([a[k], a[k + 2]], [b[k], b[k + 2]]); + let (oa, ob) = ([a[o], a[o + 2]], [b[o], b[o + 2]]); + let (lo, hi) = (oa[0].max(ob[0]), oa[1].min(ob[1])); + let at = if lo < hi { (lo + hi) / 2.0 } else { (oa[0] + oa[1]) / 2.0 }; + // Each pair: the two ends, and which of them lies on `b`'s edge. + let pairs: Vec<(f64, f64, f64)> = if sa[1] <= sb[0] { + vec![(sa[1], sb[0], sb[0])] + } else if sb[1] <= sa[0] { + vec![(sb[1], sa[0], sb[1])] + } else { + vec![(sa[0].min(sb[0]), sa[0].max(sb[0]), sb[0]), (sa[1].min(sb[1]), sa[1].max(sb[1]), sb[1])] + }; + for (from, to, edge) in pairs { + if to - from <= 0.0 { + continue; + } + out.measures.push(Measure { horizontal, at, from, to }); + // The line ends beside `b`: extend `b`'s edge to it. + if at < ob[0] || at > ob[1] { + let near = if at < ob[0] { ob[0] } else { ob[1] }; + out.extensions.push(if horizontal { [edge, near, edge, at] } else { [near, edge, at, edge] }); + } + } + } + out +} + +/// The bounds of layer `id` and, for a group, of the layers in it (`snap::layer_rect`: what the +/// Move tool moves and snaps). +fn tree_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> { + let rows = doc.walk(); + let (prefix, _, _) = rows.iter().find(|(_, _, l)| l.id == id)?; + union(rows.iter().filter(|(p, _, _)| p.starts_with(prefix)).filter_map(|(_, _, l)| layer_rect(doc, l.id))) +} + +/// The last answer: the boxes measured with the pointer at `at` on that revision and selection. +pub(crate) struct Cache { + doc: DocId, + revision: u64, + at: [i32; 2], + group: bool, + selected: Vec, + boxes: Option<([f64; 4], [f64; 4])>, +} + +/// The selected layers' box and the box to measure it to with the pointer at `at` (document px): +/// the layer a click there would Auto-Select, or the canvas where that is no other layer. +fn boxes(app: &mut PhotocraftApp, at: [f64; 2]) -> Option<([f64; 4], [f64; 4])> { + if !at[0].is_finite() || !at[1].is_finite() { + return None; + } + let at = [at[0].floor() as i32, at[1].floor() as i32]; + let group = app.ui.tool_options.move_target == "group"; + let st = app.session.active()?; + let selected = st.selected_layers(); + let (doc, revision) = (st.doc.id, st.revision); + if let Some(c) = app.move_distance.as_ref().filter(|c| c.doc == doc && c.revision == revision && c.at == at && c.group == group && c.selected == selected) { + return c.boxes; + } + let d = &st.doc; + let a = union(selected.iter().filter(|id| !d.is_background(**id)).filter_map(|id| tree_rect(d, *id))); + let canvas = [0.0, 0.0, d.size.width as f64, d.size.height as f64]; + let hovered = photocraft_engine::pick_cmds::auto_select_target(d, at[0], at[1], group).filter(|id| { + // The selection itself (or a layer in a selected group) isn't another layer. + !d.is_background(*id) && !selected.iter().any(|s| *s == *id || d.path_of(*id).zip(d.path_of(*s)).is_some_and(|(p, q)| p.starts_with(&q))) + }); + let boxes = a.map(|a| (a, hovered.and_then(|id| tree_rect(d, id)).unwrap_or(canvas))); + app.move_distance = Some(Cache { doc, revision, at, group, selected, boxes }); + boxes +} + +/// What to measure now: the ⌥-drag's original and copy while it duplicates, else, with ⌘ held +/// (`command`) and no button down (`pressed`), the selection and what's under the pointer `at`. +/// `None` when Smart Guides are off or another tool is current. +pub(crate) fn current(app: &mut PhotocraftApp, at: Option<[f64; 2]>, command: bool, pressed: bool) -> Option { + if app.ui.tool != Tool::Move || !app.ui.view.shows(app.ui.view.show.smart_guides) || app.ui.transform.is_some() { + return None; + } + if let Some((original, copy)) = duplicate_drag(app) { + return Some(measure(original, copy)); + } + if !command || pressed || app.drag.is_some() || app.guide_drag.is_some() { + return None; + } + let (a, b) = boxes(app, at?)?; + Some(measure(a, b)) +} + +/// An ⌥-drag that has duplicated the layers: the original's box and the copy's where it is now. +fn duplicate_drag(app: &PhotocraftApp) -> Option<([f64; 4], [f64; 4])> { + if !crate::move_mods::duplicated(app) { + return None; + } + let snap = app.prefs_rt.snap.as_ref()?; + let crate::snap_ui::Gesture::Move { rect } = &snap.gesture else { return None }; + let p = app.drag.as_ref()?.points.last()?; + let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; + Some((*rect, [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]])) +} + +/// Black or white, whichever reads better on `c`. +fn on(c: Color32) -> Color32 { + let l = 0.299 * f32::from(c.r()) + 0.587 * f32::from(c.g()) + 0.114 * f32::from(c.b()); + if l > 150.0 { Color32::BLACK } else { Color32::WHITE } +} + +/// Draws the measurements on the active document's canvas in the Smart Guides colour. +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { + if app.session.active_index() != Some(idx) { + return; + } + let (mods, pressed) = painter.ctx().input(|i| (i.modifiers, i.pointer.any_down())); + let command = crate::workspace_ui::sticky_mods(app, mods).command; + let at = hover.map(|p| xf.to_doc(p)); + let Some(overlay) = current(app, at, command, pressed) else { return }; + let Some(st) = app.session.active() else { return }; + let (dpi, size) = (f64::from(st.doc.resolution_dpi), st.doc.size); + let ur = &app.session.prefs().units_and_rulers; + let color = crate::snap_ui::smart_color(app); + let stroke = Stroke::new(1.0, color); + let px = |p: Pos2| pos2(p.x.round() + 0.5, p.y.round() + 0.5); + for e in &overlay.extensions { + let (a, b) = (xf.to_screen(e[0] as f32, e[1] as f32), xf.to_screen(e[2] as f32, e[3] as f32)); + painter.add(egui::Shape::dashed_line(&[px(a), px(b)], stroke, 3.0, 3.0)); + } + let t = crate::theme::Tokens::get(painter.ctx()); + let font = egui::FontId::proportional(11.0); + for m in &overlay.measures { + let (a, b) = if m.horizontal { + (xf.to_screen(m.from as f32, m.at as f32), xf.to_screen(m.to as f32, m.at as f32)) + } else { + (xf.to_screen(m.at as f32, m.from as f32), xf.to_screen(m.at as f32, m.to as f32)) + }; + let (a, b) = (px(a), px(b)); + painter.line_segment([a, b], stroke); + // End caps across the line. + let dir = (b - a).normalized(); + let cap = vec2(-dir.y, dir.x) * 4.0; + for p in [a, b] { + painter.line_segment([p - cap, p + cap], stroke); + } + let extent = if m.horizontal { size.width } else { size.height } as f64; + let text = format!("{} {}", ur.format(m.len(), dpi, extent), ur.rulers.suffix()); + let galley = painter.layout_no_wrap(text, font.clone(), on(color)); + let mid = a + (b - a) / 2.0; + let r = egui::Rect::from_center_size(mid, galley.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, t.radius_sm, color); + painter.galley(r.center() - galley.size() / 2.0, galley, on(color)); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn m(horizontal: bool, at: f64, from: f64, to: f64) -> Measure { + Measure { horizontal, at, from, to } + } + + #[test] + fn boxes_apart_measure_the_gap_between_facing_edges() { + // `b` 60 px right of `a`, overlapping it vertically from 20 to 40: the gap runs through + // the middle of that overlap (y = 30). + let o = measure([0.0, 0.0, 40.0, 40.0], [100.0, 20.0, 150.0, 80.0]); + // Vertically they overlap: the corresponding edges (tops 0 → 20, bottoms 40 → 80), through + // `a`'s centre (x = 20) as they don't overlap horizontally, with `b`'s top and bottom + // edges extended to it. + assert_eq!(o.measures, vec![m(true, 30.0, 40.0, 100.0), m(false, 20.0, 0.0, 20.0), m(false, 20.0, 40.0, 80.0)]); + assert_eq!(o.extensions, vec![[100.0, 20.0, 20.0, 20.0], [100.0, 80.0, 20.0, 80.0]]); + } + + #[test] + fn diagonal_boxes_measure_both_gaps_from_the_selection_centre() { + // `b` below-left of `a`: 30 px apart horizontally, 50 px vertically. + let o = measure([100.0, 0.0, 140.0, 20.0], [20.0, 70.0, 70.0, 90.0]); + assert_eq!(o.measures, vec![m(true, 10.0, 70.0, 100.0), m(false, 120.0, 20.0, 70.0)]); + // Both lines end beside `b`: its right edge reaches up to y = 10, its top edge across to x = 120. + assert_eq!(o.extensions, vec![[70.0, 70.0, 70.0, 10.0], [70.0, 70.0, 120.0, 70.0]]); + } + + #[test] + fn inside_the_canvas_measures_to_all_four_edges() { + let o = measure([40.0, 40.0, 90.0, 80.0], [0.0, 0.0, 400.0, 300.0]); + assert_eq!(o.measures, vec![m(true, 60.0, 0.0, 40.0), m(true, 60.0, 90.0, 400.0), m(false, 65.0, 0.0, 40.0), m(false, 65.0, 80.0, 300.0)]); + assert!(o.extensions.is_empty()); + // Edges that touch measure nothing; nonsense boxes nothing at all. + assert_eq!(measure([0.0, 0.0, 400.0, 300.0], [0.0, 0.0, 400.0, 300.0]), Overlay::default()); + assert_eq!(measure([f64::NAN, 0.0, 1.0, 1.0], [0.0, 0.0, 400.0, 300.0]), Overlay::default()); + } + + /// The `snap_ui` test document: a target box at (300..350, 200..260) and the selected mover + /// at (40..90, 40..80) on a 400×300 canvas. + fn app_with_boxes() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("layer.new.layer", json!({"name": "target"})).unwrap(); + app.run("select.rect", json!({"x": 300, "y": 200, "width": 50, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layer", json!({"name": "mover"})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 40, "width": 50, "height": 40})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.view.extras = true; + app.ui.view.show.smart_guides = true; + app + } + + #[test] + fn command_hover_over_another_layer_measures_to_it() { + let mut app = app_with_boxes(); + let revision = app.session.active().unwrap().revision; + let o = current(&mut app, Some([320.5, 230.0]), true, false).unwrap(); + // 210 px across (90 → 300), 120 px down (80 → 200), from the mover's centre. + assert_eq!(o.measures, vec![m(true, 60.0, 90.0, 300.0), m(false, 65.0, 80.0, 200.0)]); + // Over empty canvas (the Background): to the canvas edges. + let o = current(&mut app, Some([200.0, 150.0]), true, false).unwrap(); + assert_eq!(o.measures, vec![m(true, 60.0, 0.0, 40.0), m(true, 60.0, 90.0, 400.0), m(false, 65.0, 0.0, 40.0), m(false, 65.0, 80.0, 300.0)]); + // Over the selected layer itself: also the canvas edges. + assert_eq!(current(&mut app, Some([60.0, 60.0]), true, false).unwrap().measures.len(), 4); + // Only an overlay: the document didn't change. + assert_eq!(app.session.active().unwrap().revision, revision); + } + + #[test] + fn nothing_without_command_smart_guides_or_the_move_tool() { + let mut app = app_with_boxes(); + let at = Some([320.5, 230.0]); + assert!(current(&mut app, at, true, false).is_some()); + // ⌘ not held, or a button down (⌘-click auto-selects, unchanged). + assert_eq!(current(&mut app, at, false, false), None); + assert_eq!(current(&mut app, at, true, true), None); + assert_eq!(current(&mut app, None, true, false), None); + // Smart Guides off, or Extras off. + app.ui.view.show.smart_guides = false; + assert_eq!(current(&mut app, at, true, false), None); + app.ui.view.show.smart_guides = true; + app.ui.view.extras = false; + assert_eq!(current(&mut app, at, true, false), None); + app.ui.view.extras = true; + // Another tool. + app.ui.tool = Tool::Brush; + assert_eq!(current(&mut app, at, true, false), None); + // Nothing to measure from: the Background is selected (the Move tool doesn't move it). + app.ui.tool = Tool::Move; + let bg = app.session.active().unwrap().doc.walk().first().unwrap().2.id; + app.run("layer.select", json!({"layer": bg.0})).unwrap(); + assert_eq!(current(&mut app, at, true, false), None); + } + + #[test] + fn alt_drag_duplicate_measures_from_the_original_to_the_copy() { + let mut app = app_with_boxes(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let alt = egui::Modifiers { alt: true, ..Default::default() }; + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, alt); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Move { x: 160.0, y: 60.0, pressure: 1.0 }, alt); + // The copy's left edge is 50 px right of the original's right edge (90 → 140); they line + // up vertically, so only the gap shows. A button is down and ⌘ isn't held. + let o = current(&mut app, None, false, true).unwrap(); + assert_eq!(o.measures, vec![m(true, 60.0, 90.0, 140.0)]); + crate::canvas::tool_event(&mut app, crate::canvas::ToolEvent::Up { x: 160.0, y: 60.0 }, alt); + assert_eq!(current(&mut app, None, false, false), None); + } + + /// Through the real canvas: ⌘ held over the other layer measures to it (the overlay is drawn + /// from the cached boxes), and a ⌘-click there still Auto-Selects it as before. + #[test] + fn command_hover_on_the_canvas_draws_and_command_click_still_picks() { + use egui::{Event, Modifiers, PointerButton, vec2}; + let mut app = app_with_boxes(); + app.ui.tool_options.move_auto_select = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + let screen = |h: &egui_kittest::Harness<'_, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) + }; + let command = if cfg!(target_os = "macos") { + Modifiers { command: true, mac_cmd: true, ..Default::default() } + } else { + Modifiers { command: true, ctrl: true, ..Default::default() } + }; + let revision = h.state().session.active().unwrap().revision; + let p = screen(&h, 320.5, 230.5); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + // Not held: nothing measured. + assert!(h.state().move_distance.is_none()); + h.event(Event::ModifiersChanged(command)); + h.run_steps(2); + let boxes = h.state().move_distance.as_ref().and_then(|c| c.boxes); + assert_eq!(boxes, Some(([40.0, 40.0, 90.0, 80.0], [300.0, 200.0, 350.0, 260.0]))); + if let Ok(path) = std::env::var("PHOTOCRAFT_MOVE_DISTANCE_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + assert_eq!(h.state().session.active().unwrap().revision, revision, "hovering changed nothing"); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: command }); + h.run_steps(1); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: command }); + h.run_steps(2); + let st = h.state().session.active().unwrap(); + let picked = st.active_layer.and_then(|id| st.doc.layer(id)).map(|l| l.name.clone()); + assert_eq!(picked.as_deref(), Some("target")); + } +} diff --git a/crates/ui-egui/src/move_hover.rs b/crates/ui-egui/src/move_hover.rs index c3d07f313d..ff9d425f84 100644 --- a/crates/ui-egui/src/move_hover.rs +++ b/crates/ui-egui/src/move_hover.rs @@ -185,7 +185,7 @@ mod tests { assert_eq!(active_outline(&mut app, None, false, false), Some(a)); // When a drag or transform is in progress, no outline: - app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0 }); + app.guide_drag = Some(crate::rulers::GuideDrag { vertical: false, index: None, pos: 10.0, press: 10.0, over_ruler: false }); assert_eq!(active_outline(&mut app, None, false, false), None); app.guide_drag = None; diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 64b3a65603..a5be52036f 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -152,6 +152,11 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; [start[0] + d[0], start[1] + d[1]] } +/// The Move drag in progress is an ⌥-drag that has duplicated the layers (it now moves the copies). +pub(crate) fn duplicated(app: &PhotocraftApp) -> bool { + moving(app) && app.move_mods.dup_from.is_some() +} + /// Call after the Move tool finished its drag: an ⌥-drag's copy and move become one step. pub fn finish(app: &mut PhotocraftApp) { let st = std::mem::take(&mut app.move_mods); diff --git a/crates/ui-egui/src/move_outline_tests.rs b/crates/ui-egui/src/move_outline_tests.rs index 7029ab9faf..48808b089f 100644 --- a/crates/ui-egui/src/move_outline_tests.rs +++ b/crates/ui-egui/src/move_outline_tests.rs @@ -38,7 +38,18 @@ fn app() -> (PhotocraftApp, LayerId, LayerId) { app.ui.view.extras = true; app.ui.view.show.layer_edges = true; // 4 screen px per document px, the document centred. - app.ui.views = vec![View { zoom: 4.0, center: [32.0, 32.0], fit_pending: false, fill_pending: false, doc_size: [64, 64], rotation: 0.0 }]; + app.ui.views = vec![View { + zoom: 4.0, + center: [32.0, 32.0], + fit_pending: false, + fill_pending: false, + doc_size: [64, 64], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }]; (app, ids[0], ids[1]) } diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index fb49b02474..5115fa98f9 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -39,7 +39,10 @@ pub(crate) fn float_selected(app: &mut PhotocraftApp, copy: bool, dx: f64, dy: f let floating = app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()); let dropped = if copy && floating { app.run("select.drop", json!({})) } else { Ok(json!(null)) }; match dropped.and_then(|_| app.run("select.float", json!({"dx": dx, "dy": dy, "copy": copy}))) { - Ok(_) => true, + Ok(_) => { + crate::snap_ui::follow_cut(app); + true + } Err(e) => { app.ui.status = e; app.ui.status_error = true; @@ -96,6 +99,15 @@ fn drag_offset(app: &PhotocraftApp) -> Option<(i32, i32)> { Some((dx, dy)) } +/// The ΔX/ΔY values shown beside the pointer while the Move tool drags layers or selected pixels, +/// as in Photoshop (#5727): the whole pixels the release will move them. Free Transform shows its +/// own (`transform_tool::live_readout`). `None` until the pointer moves. +pub(crate) fn offset_readout(app: &PhotocraftApp) -> Option<[String; 2]> { + app.drag.as_ref().filter(|d| d.points.len() > 1)?; + let (dx, dy) = drag_offset(app)?; + Some([dx, dy].map(|v| format!("{v} px"))) +} + /// The document to show while a Move drag is under way on document `idx`: the moving layers at /// the pointer. `None` without a drag (or when the layers can't move: locked, say; the drag then /// shows its arrow and the release reports why). @@ -213,6 +225,9 @@ fn floating_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u floating: true, }); } + if offset == (0, 0) && app.move_preview.as_ref().is_none_or(|p| p.offsets.is_empty()) { + return None; + } let st = app.session.documents().get(idx)?; let shown = app.move_preview.as_ref()?.offsets.last() != Some(&offset); if shown { @@ -256,16 +271,26 @@ pub(crate) fn is_preview_key(key: u64) -> bool { key > BASE && key < BASE << 1 } +/// Does a Move drag of whole layers take the selection along? With several layers selected, as +/// in Photoshop (#7538): their selected pixels can't float, so the layers move and the marching +/// ants follow them. +pub(crate) fn carries_selection(app: &PhotocraftApp) -> bool { + app.session.active().is_some_and(|st| st.doc.selection.is_some() && st.selected_layers().len() > 1) +} + /// Release: one `layer.translate` by the drag's offset. The canvas already shows the result, so /// its caches are told it showed the document: the command's damage rect refreshes that area. pub(crate) fn finish(app: &mut PhotocraftApp, dx: f64, dy: f64) { + // Within the range the preview and the ΔX/ΔY readout show (`drag_offset`), so the release + // commits the offset they showed even at the lowest zoom of a huge document. + let (dx, dy) = (dx.clamp(-1e7, 1e7), dy.clamp(-1e7, 1e7)); // Only when the canvas shows this very offset (else it recomposites everything once). - let at = (dx.clamp(-1e7, 1e7) as i32, dy.clamp(-1e7, 1e7) as i32); + let at = (dx as i32, dy as i32); let shown = app.move_preview.take().filter(|p| p.shown.is_some() && p.offsets.last() == Some(&at)); if dx == 0.0 && dy == 0.0 { return; } - if app.run("layer.translate", json!({"dx": dx, "dy": dy})).is_err() { + if app.run("layer.translate", json!({"dx": dx, "dy": dy, "selection": carries_selection(app)})).is_err() { return; } let Some(p) = shown else { return }; @@ -309,6 +334,52 @@ mod tests { assert_eq!(r, Rect::new(0, 0, 64, 64)); } + /// The Move tool shows how far it has moved the layer, ΔX/ΔY in whole pixels, as Photoshop + /// and Free Transform do (#5727): once the pointer moves, until the release. + #[test] + fn a_move_drag_reads_out_the_offset_it_will_commit() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, a| { + doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(16, 16, 32, 32), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let m = egui::Modifiers::NONE; + assert_eq!(super::offset_readout(&app), None, "no drag"); + // Pressed on the red square (16..32): with Auto-Select (on by default) an empty spot would + // start the layer marquee instead of a move. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 24.0, y: 24.0, pressure: 1.0 }, m); + assert_eq!(super::offset_readout(&app), None, "pressed, not moved yet"); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 34.4, y: 20.8, pressure: 1.0 }, m); + assert_eq!(super::offset_readout(&app), Some(["10 px".to_string(), "-3 px".to_string()])); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 34.4, y: 20.8 }, m); + assert_eq!(super::offset_readout(&app), None, "released"); + } + + /// A release past the ±10,000,000 px the preview and the readout show commits the offset they + /// showed, not the raw pointer distance. + #[test] + fn a_release_commits_the_offset_the_readout_caps_at() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + super::finish(&mut app, 2.5e7, -2.5e7); + let st = app.session.active().unwrap(); + let s = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()).unwrap(); + assert!(s.pixel(16 + 10_000_000, 16 - 10_000_000)[3] > 0.5, "moved by the capped offset"); + } + /// A shape reaching past the canvas edge renders cut at the canvas, but its path goes on: a /// drag frame must redraw where the rest of it was, or the cut edge stays behind (#3946). #[test] @@ -478,6 +549,28 @@ mod tests { } } + /// #7538: a loose marquee round the red square shrinks to it as the drag starts: the canvas + /// outlines the moved pixels while they float, and the dropped selection is theirs. Alt-drag + /// (a copy) too. + #[test] + fn dragging_selected_pixels_shrinks_the_ants_to_them() { + for depth in [8, 16, 32] { + for m in [egui::Modifiers::NONE, egui::Modifiers::ALT] { + let (mut app, _) = selected_pixels(depth); + app.session.execute("select.rect", json!({"x": 2, "y": 2, "width": 30, "height": 30})).unwrap(); + drag(&mut app, [16.0, 16.0], [26.0, 21.0], m); + assert_eq!(offset(&app), Some((10, 5)), "{depth}-bit, {m:?}: floating"); + let ants = crate::canvas::floating_outline(&app, 0).map(|s| s.content_bounds()); + assert_eq!(ants, Some(Rect::new(8, 8, 24, 24)), "{depth}-bit, {m:?}: the ants hug the red square"); + assert!(crate::canvas::floating_outline(&app, 1).is_none(), "only the active document's"); + app.run("select.drop", json!({})).unwrap(); + let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert_eq!(sel, Some(Rect::new(18, 13, 34, 29)), "{depth}-bit, {m:?}: dropped"); + assert!(crate::canvas::floating_outline(&app, 0).is_none(), "nothing floats"); + } + } + } + /// ⌘-drag with a painting tool drags the selected pixels as the Move tool does, also when ⌘ /// comes with the event (control channel, MCP) rather than as the held-key override. #[test] @@ -535,4 +628,20 @@ mod tests { drag(&mut app, [16.0, 16.0], [26.0, 16.0], egui::Modifiers::NONE); assert!(alpha(&app, id, 10, 10) == 0.0 && alpha(&app, id, 58, 16) == 1.0, "the whole layer moved"); } + + #[test] + fn floating_selection_does_not_snap_to_its_own_starting_outline_or_preview_at_zero() { + let (mut app, _id) = selected_pixels(8); + app.ui.extras.snap = true; + app.ui.view.snap_to.document_bounds = false; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, m); + assert!(super::display_doc(&mut app, 0).is_none(), "no preview composite at offset (0, 0) before the pointer moves"); + // A small 2 px move within the snap radius must move immediately instead of snapping back to (0, 0). + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 18.0, y: 17.0, pressure: 1.0 }, m); + assert_eq!(super::floating_offset(&app), Some((2, 1)), "small drag moves without snapping to own selection bounds"); + assert!(super::display_doc(&mut app, 0).is_some(), "preview is shown once offset is non-zero"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 18.0, y: 17.0 }, m); + assert_eq!(offset(&app), Some((2, 1))); + } } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index e76c20eecd..2beef91f94 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -546,7 +546,7 @@ fn language_node(pref: &str, lang: Lang) -> Node { role: Some(ItemRole::Language), }; let mut children = vec![Node::Item(item("auto", tr(lang, "Auto"))), Node::Separator]; - children.extend(Lang::all().map(|l| Node::Item(item(l.code(), l.name())))); + children.extend(Lang::for_picker().into_iter().map(|l| Node::Item(item(l.code(), l.name())))); Node::Submenu { label: tr(lang, "Language").into(), children, role: Some(ItemRole::Language) } } @@ -865,7 +865,7 @@ mod tests { }; let themes = ids(children); assert_eq!(themes.last().map(String::as_str), Some("window.theme.toggle")); - assert!(themes.contains(&"window.theme.pro".to_string()) && themes.contains(&"window.theme.classic".to_string())); + assert!(themes.contains(&"[Dark Theme]".to_string()) && themes.contains(&"[Light Theme]".to_string())); assert_eq!(l.bar.find("window.theme.classic").unwrap().checked, Some(true)); assert_eq!(l.bar.find("window.theme.pro").unwrap().checked, Some(false)); assert_eq!(l.bar.find("window.theme.toggle").unwrap().label, "Next Appearance Mode"); diff --git a/crates/ui-egui/src/navigator_gpu_tests.rs b/crates/ui-egui/src/navigator_gpu_tests.rs index 454181ff72..0fa423a692 100644 --- a/crates/ui-egui/src/navigator_gpu_tests.rs +++ b/crates/ui-egui/src/navigator_gpu_tests.rs @@ -72,6 +72,20 @@ fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() { assert!(near(preview_px(&mut h, 0.75, 0.5), [0, 0, 255]), "right half now blue"); assert!(!h.state().navigator_textures.contains_key(&id)); + // Dragging the selected pixels with the Move tool keeps the Navigator on the GPU texture + // without falling back to a full-document CPU thumbnail composite on the UI thread. + h.state_mut().ui.tool = crate::state::Tool::Move; + crate::canvas::tool_event(h.state_mut(), crate::canvas::ToolEvent::Down { x: 250.0, y: 150.0, pressure: 1.0 }, egui::Modifiers::NONE); + crate::canvas::tool_event(h.state_mut(), crate::canvas::ToolEvent::Move { x: 150.0, y: 150.0, pressure: 1.0 }, egui::Modifiers::NONE); + h.run_steps(4); + assert!(h.state().navigator_gpu.contains_key(&id), "Navigator stays on GPU canvas during Move drag"); + assert!(!h.state().navigator_textures.contains_key(&id), "no CPU thumbnail built during Move drag"); + crate::canvas::tool_event(h.state_mut(), crate::canvas::ToolEvent::Up { x: 150.0, y: 150.0 }, egui::Modifiers::NONE); + h.state_mut().run("select.drop", json!({})).unwrap(); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("select.deselect", json!({})).unwrap(); + h.run_steps(4); + // A flipped view draws on the CPU: the Navigator goes back to its CPU thumbnail. h.state_mut().ui.view.flip_horizontal = true; h.run_steps(6); diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e615620d4c..4f09d6a028 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -231,6 +231,33 @@ pub fn apply_preset(f: &mut Map, p: &Preset, units: &UnitsAndRule f.insert("__unit".into(), json!(unit)); } +/// The colour mode of the dialog's `mode` value. +fn color_mode_of(mode: &str) -> Option { + use photocraft_color::ColorMode; + match mode { + "rgb" => Some(ColorMode::Rgb), + "gray" | "grayscale" => Some(ColorMode::Grayscale), + "cmyk" => Some(ColorMode::Cmyk), + "lab" => Some(ColorMode::Lab), + _ => None, + } +} + +/// The Color Profile menu for a document of colour `space` (#321): the working space (shown with +/// its profile's description), then the built-in and installed profiles of that space, as +/// `file.new`'s `profile` values (an id or a path) with their labels. +fn profile_options( + space: Option, + working: &str, + installed: &[photocraft_engine::installed_profiles::InstalledProfile], +) -> Vec<(String, String)> { + let mut v = vec![("working".to_string(), crate::i18n::fmt(tl!("Working: {working}"), &[("working", working)]))]; + let Some(space) = space else { return v }; + v.extend(photocraft_cms::Builtin::ALL.iter().filter(|b| b.profile().color_space == space).map(|b| (b.id().to_string(), b.description().to_string()))); + v.extend(installed.iter().filter(|i| i.is_destination() && i.color_space == space).map(|i| (i.path.clone(), i.description.clone()))); + v +} + /// Fields `file.new` takes (drops the dialog's `__` UI keys). pub fn command_params(f: &Map) -> Value { Value::Object(f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect()) @@ -319,6 +346,8 @@ pub fn apply_saved_preset(f: &mut Map, preset: &photocraft_engine /// Restore every setting of a saved or recent document (all but its name). pub fn apply_settings(f: &mut Map, settings: &DocumentSettings) { f.remove("backgroundColor"); + // A preset without a Color Profile is on the working space, whatever was picked before. + f.remove("profile"); if let Some(params) = settings.command_params().as_object() { f.extend(params.clone()); } @@ -424,7 +453,8 @@ fn saved_presets(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map< let response = ui.add_sized(vec2(422.0, 32.0), label).on_hover_text(&preset.name); if response.clicked() { apply_saved_preset(f, preset); - create |= response.double_clicked() || response.triple_clicked(); + // Touch-tolerant double-tap (finger jitter > egui's 6 px); see `widgets::second_click`. + create |= widgets::second_click(ui, &response); } if widgets::secondary_button(ui, tl!("Delete"), 76.0).clicked() { match app.run("document.presets.delete", json!({"name":preset.name})) { @@ -488,7 +518,8 @@ fn save_preset(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> bool { let t = Tokens::get(ui.ctx()); + // Headings and field labels are static, as in Photoshop: no text cursor over them, no + // drag-selecting them (#8331). Text fields keep theirs. + ui.style_mut().interaction.selectable_labels = false; let cat = get_s(f, "__category", "Recent"); let mut create = false; // Category tabs. @@ -594,7 +628,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map apply_preset(f, p, &units), Card::Recent(settings) => apply_settings(f, settings), } - create |= resp.double_clicked() || resp.triple_clicked(); + // Touch-tolerant double-tap (finger jitter > egui's 6 px); see `widgets::second_click`. + create |= widgets::second_click(ui, &resp); } } }); @@ -695,6 +730,8 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = opts.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); + if widgets::dropdown(ui, "nd-profile", &mut profile, &shown, 240.0) { + if profile == "working" { + f.remove("profile"); + } else { + f.insert("profile".into(), json!(profile)); + } + f.remove("__savedPreset"); + } + } + ui.add_space(4.0); small_label(ui, tl!("Background Contents")); // A custom colour is kept as its "#rrggbb", which `file.new` takes as is (#4789). let current = get_s(f, "background", "white"); @@ -768,6 +831,55 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map>() + }; + let rgb = values("rgb"); + assert_eq!(rgb[0], "working"); + assert!(rgb.contains(&Builtin::AdobeRgbCompat.id().to_string()) && rgb.contains(&"panel.icc".to_string())); + assert!(!rgb.contains(&Builtin::CoatedCmyk.id().to_string()) && !rgb.contains(&"press.icc".to_string())); + let cmyk = values("cmyk"); + assert!(cmyk.contains(&"press.icc".to_string()) && !cmyk.contains(&Builtin::Srgb.id().to_string())); + assert!(profile_options(None, "sRGB", &installed).len() == 1, "an unknown mode offers only the working space"); + // Every entry is one `file.new` takes for that mode. + for mode in ["rgb", "gray", "cmyk", "lab"] { + for v in values(mode).into_iter().filter(|v| !v.ends_with(".icc")) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2, "height": 2, "mode": mode, "profile": v})).unwrap_or_else(|e| panic!("{mode} {v}: {e}")); + } + } + } + + /// A saved preset replaces the Color Profile picked before: its own, or the working space. + #[test] + fn applying_a_preset_replaces_the_picked_profile() { + let mut s = photocraft_engine::Session::new(); + let base = json!({"width": 8, "height": 8, "mode": "cmyk", "unit": "px"}); + s.execute("document.presets.save", json!({"name": "Plain", "settings": base})).unwrap(); + let mut press = base.clone(); + press["profile"] = json!("coated-cmyk"); + s.execute("document.presets.save", json!({"name": "Press", "settings": press})).unwrap(); + let mut f = Map::new(); + f.insert("profile".into(), json!(photocraft_cms::Builtin::AdobeRgbCompat.id())); + apply_saved_preset(&mut f, &s.presets.documents[0]); + assert!(!f.contains_key("profile"), "{f:?}"); + assert!(s.execute("file.new", command_params(&f)).is_ok()); + apply_saved_preset(&mut f, &s.presets.documents[1]); + assert_eq!(f["profile"], "coated-cmyk"); + } + #[test] fn units_round_trip_through_pixels() { assert_eq!(to_unit(2100.0, "in", 300.0), 7.0); @@ -944,6 +1056,7 @@ mod tests { depth: 8, background: "white".into(), background_color: None, + profile: None, unit: "px".into(), resolution_unit: "in".into(), }; @@ -1208,6 +1321,17 @@ mod tests { h.run_steps(2); } + /// A finger tap at `at`. Events for one phase share a frame (`input_mut`), because `Harness::step` would otherwise run one frame per queued event and drop Touch before the click release (so `second_click` would not see touch). + fn touch_tap_at(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) { + let touch = |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos: at, force: None }; + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.extend([egui::Event::PointerMoved(at), touch(egui::TouchPhase::Start), button(true)]); + h.step(); + h.input_mut().events.extend([touch(egui::TouchPhase::End), button(false)]); + h.step(); + h.run_steps(1); + } + /// The Width (0), Height (1) and Resolution (2) fields. fn field(h: &Harness<'static, PhotocraftApp>, i: usize) -> egui::Rect { h.query_all_by_role(Role::SpinButton).nth(i).map(|n| n.rect()).expect("a size field") @@ -1273,6 +1397,37 @@ mod tests { } } + fn cursor_over(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) -> egui::CursorIcon { + h.hover_at(at); + h.run_steps(2); + h.output().platform_output.cursor_icon + } + + /// #8331: headings and field labels keep the arrow; only the text fields show the I-beam. + #[test] + fn labels_show_the_arrow_and_only_text_fields_the_text_cursor() { + use egui_kittest::kittest::NodeT; + let mut h = harness(); + let label_at = |h: &Harness<'static, PhotocraftApp>, text: &str| { + h.query_all_by_label(text) + .find(|n| n.accesskit_node().role() == Role::Label) + .map(|n| n.rect().center()) + .unwrap_or_else(|| panic!("a {text:?} label")) + }; + for text in ["PRESET DETAILS", "Width", "Height", "Resolution", "Color Mode", "Background Contents"] { + let at = label_at(&h, text); + assert_eq!(cursor_over(&mut h, at), egui::CursorIcon::Default, "over {text:?}"); + } + // The name field still shows the text cursor, so the check above can tell them apart. + let name = h.query_all_by_role(Role::TextInput).next().map(|n| n.rect().center()).expect("the name field"); + assert_eq!(cursor_over(&mut h, name), egui::CursorIcon::Text, "over the name field"); + // The Saved tab's labels too. + h.get_by_label("Saved").click(); + h.run_steps(2); + let at = label_at(&h, "No saved presets yet."); + assert_eq!(cursor_over(&mut h, at), egui::CursorIcon::Default, "over the empty Saved list"); + } + #[test] fn double_click_builtin_preset_creates_its_size_and_current_details() { let mut h = harness(); @@ -1297,6 +1452,33 @@ mod tests { assert_pixel(&mut h, [0.0; 4]); } + /// Framework-style touch: the second tap lands ~48 px off (past egui's 6 px and the old 32 px touch slop) and still creates. + #[test] + fn touch_double_tap_with_jitter_creates_from_builtin_preset() { + let mut h = harness(); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + touch_tap_at(&mut h, at); + assert_eq!(fields(&h)["__preset"], "Portrait, 4 x 6"); + assert!(h.state().session.documents().is_empty(), "first tap only selects"); + touch_tap_at(&mut h, egui::pos2(at.x + 48.0, at.y)); + assert_eq!(created(&h), (1200, 1800, 300.0)); + } + + /// Mouse keeps egui's tight distance: 20 px between clicks is two singles, not a create. + #[test] + fn mouse_double_click_with_large_jitter_does_not_create() { + let mut h = harness(); + h.get_by_label("Photo").click(); + h.run_steps(2); + let at = preset_title_at(&h, "Portrait, 4 x 6"); + click_at(&mut h, at); + click_at(&mut h, at + egui::vec2(20.0, 0.0)); + assert!(h.state().session.documents().is_empty()); + assert_eq!(h.state().ui.dialogs.len(), 1, "dialog stays open"); + } + #[test] fn double_click_clipboard_preset_creates_its_size_and_current_details() { let mut h = harness(); diff --git a/crates/ui-egui/src/notice_text_tests.rs b/crates/ui-egui/src/notice_text_tests.rs new file mode 100644 index 0000000000..c7da4560a6 --- /dev/null +++ b/crates/ui-egui/src/notice_text_tests.rs @@ -0,0 +1,135 @@ +//! An error notice's message can be copied (#7807): text dragged across in it is copied by ⌘C, +//! as egui-winit sends it (`Event::Copy`) and as the Mac menu's key equivalent sends it (a ⌘C +//! press), instead of Edit › Copy copying the layer; the drag never reaches the canvas. A plain +//! click on the text leaves ⌘C to the document. The whole app (eframe harness) with the app's own +//! `raw_input_hook`, which kittest doesn't run, so the clipboard keys are mapped as on the desktop. + +use egui::{Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// The refusal the report quotes, as the notice shows it. +const MESSAGE: &str = "Free Transform: not enough memory for transforming pixels (the transformed layer \"Layer 1\" would be 37708 x 35259 px, more than the 1073741824 px limit); the document was not changed"; + +/// The whole app with a filled layer on a 2000 x 1500 document, the Move tool, and the refusal in +/// an error notice. +fn app_harness() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(8).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 2000, "height": 1500})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::Move; + app + }); + h.run_steps(4); + crate::notices::error(h.state_mut(), MESSAGE.to_string()); + h.run_steps(3); + h +} + +/// The notice's message (the status bar shows it too, on one line: the notice wraps it). +fn notice_text(h: &Harness<'static, PhotocraftApp>) -> egui::Rect { + let rects: Vec = h.get_all_by_label(MESSAGE).map(|n| n.rect()).collect(); + rects.into_iter().max_by(|a, b| a.height().total_cmp(&b.height())).unwrap() +} + +/// The document's layers (bounds) and History length. +fn document(h: &Harness<'static, PhotocraftApp>) -> (Vec>, usize) { + let st = h.state().session.active().unwrap(); + (st.doc.walk().iter().map(|(_, _, l)| l.surface().map(|s| s.content_bounds())).collect(), st.history.past_len()) +} + +/// One frame with `events`, through the app's `raw_input_hook` as on the desktop; the text it +/// copied. +fn frame(h: &mut Harness<'static, PhotocraftApp>, events: Vec) -> Vec { + let ctx = h.ctx.clone(); + let mut raw = h.input_mut().clone(); + raw.events.extend(events); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.output().platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }).collect() +} + +/// The primary button pressed or released at `p`, then a frame without events. +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) { + frame(h, vec![egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + frame(h, vec![]); +} + +/// A primary-button drag from `from` to `to` in ten moves. +fn drag(h: &mut Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2) { + frame(h, vec![egui::Event::PointerMoved(from)]); + button(h, from, true); + for i in 1..=10 { + frame(h, vec![egui::Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))]); + } + button(h, to, false); +} + +/// A ⌘C press, as the Mac menu's key equivalent sends it. +fn command_c() -> egui::Event { + egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND } +} + +/// Text dragged across in the notice is what ⌘C copies, as a Copy event or a ⌘C press; the drag +/// and the copy leave the layers, History and the session clipboard alone. +#[test] +fn copy_takes_the_text_dragged_across_in_an_error_notice_not_the_layer() { + for (how, copy) in [("Event::Copy", egui::Event::Copy), ("a ⌘C press", command_c())] { + let mut h = app_harness(); + let before = document(&h); + let r = notice_text(&h); + drag(&mut h, r.left_top() + vec2(2.0, 6.0), r.right_bottom() - vec2(2.0, 6.0)); + assert_eq!(document(&h), before, "{how}: the drag across the notice stays off the canvas"); + let copied = frame(&mut h, vec![copy]); + frame(&mut h, vec![]); + assert!(copied.iter().any(|t| t.contains("1073741824 px limit")), "{how}: the selected text is copied: {copied:?}"); + assert!(h.state().session.clipboard.is_none(), "{how}: Edit › Copy didn't copy the layer"); + assert_eq!(document(&h), before, "{how}"); + } +} + +/// The selection outlives the pointer: after the drag the pointer moves off the notice and over +/// the canvas without a press, and ⌘C still copies the text, not the layer. +#[test] +fn copy_takes_the_selected_text_after_the_pointer_leaves_the_notice() { + for (how, copy) in [("Event::Copy", egui::Event::Copy), ("a ⌘C press", command_c())] { + let mut h = app_harness(); + let before = document(&h); + let r = notice_text(&h); + drag(&mut h, r.left_top() + vec2(2.0, 6.0), r.right_bottom() - vec2(2.0, 6.0)); + let canvas = Pos2::new(500.0, 400.0); + assert!(!r.contains(canvas)); + for i in 1..=10 { + frame(&mut h, vec![egui::Event::PointerMoved(r.right_bottom() + (canvas - r.right_bottom()) * (i as f32 / 10.0))]); + } + let copied = frame(&mut h, vec![copy]); + frame(&mut h, vec![]); + assert!(copied.iter().any(|t| t.contains("1073741824 px limit")), "{how}: the selected text is copied: {copied:?}"); + assert!(h.state().session.clipboard.is_none(), "{how}: Edit › Copy didn't copy the layer"); + assert_eq!(document(&h), before, "{how}"); + } +} + +/// A plain click on the notice's text selects nothing, so ⌘C stays Edit › Copy and copies the +/// layer. +#[test] +fn a_plain_click_on_a_notice_leaves_copy_to_the_document() { + let mut h = app_harness(); + let p = notice_text(&h).center(); + frame(&mut h, vec![egui::Event::PointerMoved(p)]); + button(&mut h, p, true); + button(&mut h, p, false); + let copied = frame(&mut h, vec![egui::Event::Copy]); + frame(&mut h, vec![]); + assert!(copied.is_empty(), "{copied:?}"); + assert!(h.state().session.clipboard.is_some(), "Edit › Copy copies the layer"); +} diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs index 01fe50e502..267c326775 100644 --- a/crates/ui-egui/src/object_finder.rs +++ b/crates/ui-egui/src/object_finder.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use egui::{Color32, TextureOptions, pos2}; use photocraft_algo::selection::Region; use photocraft_doc::{DocId, Document, LayerId}; -use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; +use photocraft_engine::smartselect_cmds::{empty_sampled_area, find_object, sampled_target}; use crate::PhotocraftApp; use crate::canvas::ViewXform; @@ -57,6 +57,13 @@ pub(crate) struct Update { pub shown: Option>, /// A lookup is pending or running: repaint soon to pick it up. pub busy: bool, + /// Why nothing can be found here: [`empty_layer_hint`] when the sampled layer has no pixels under the pointer. + pub hint: Option<&'static str>, +} + +/// The status-bar hint while hovering where the sampled layer has no pixels (the click reports the same as an error). +fn empty_layer_hint() -> &'static str { + tl!("The active layer has no pixels here — turn on Sample All Layers or select a layer with pixels") } /// Whether the preview applies now (`pressed`: a pointer button is down). @@ -87,6 +94,11 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); } let doc_arc = Arc::clone(&st.doc); + // The click reports an empty sampled layer; the hover says so too instead of showing nothing. + let here = photocraft_geom::Rect::from_xywh(px[0], px[1], 1, 1); + if doc_arc.bounds().contains(px[0], px[1]) && empty_sampled_area(&doc_arc, layer, all_layers, here).is_some() { + return Update { shown: None, busy: false, hint: Some(empty_layer_hint()) }; + } let Some(f) = app.object_finder.as_mut() else { return Update::default() }; if f.at != px { f.at = px; @@ -121,7 +133,7 @@ pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: boo f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); } let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); - Update { shown: f.shown.clone(), busy } + Update { shown: f.shown.clone(), busy, hint: None } } fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { @@ -193,6 +205,15 @@ pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXf if up.busy { ctx.request_repaint_after(std::time::Duration::from_millis(40)); } + match up.hint { + Some(h) if app.ui.status != h => { + app.ui.status = h.to_owned(); + app.ui.status_error = false; + } + // The pointer moved on: drop our hint, never another message. + None if app.ui.status == empty_layer_hint() => app.ui.status.clear(), + _ => {} + } let Some(r) = up.shown else { return }; let Some(f) = app.object_finder.as_mut() else { return }; let key = Arc::as_ptr(&r) as usize; @@ -271,6 +292,19 @@ mod tests { assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); } + #[test] + fn hovering_an_empty_active_layer_gives_a_hint_not_silence() { + let mut app = disc_app(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 100.0); + assert!(u.shown.is_none() && !u.busy); + assert_eq!(u.hint, Some(empty_layer_hint())); + // Sample All Layers on: no hint, the object is looked up. + app.ui.tool_options.sample_all.object_selection = true; + assert!(update(&mut app, Some([100.0, 80.0]), false, 100.0).hint.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + } + #[test] fn preview_clears_on_leave_tool_switch_button_option_and_edits() { let mut app = disc_app(); diff --git a/crates/ui-egui/src/opacity_keys.rs b/crates/ui-egui/src/opacity_keys.rs index 71afc3649e..e9d8f0f33e 100644 --- a/crates/ui-egui/src/opacity_keys.rs +++ b/crates/ui-egui/src/opacity_keys.rs @@ -1,6 +1,6 @@ //! Number keys set opacity (#352): 1 = 10% … 9 = 90%, 0 = 100%; two digits //! typed quickly set an exact value (4 then 5 = 45%, 0 then 0 = 0%). With the Brush, Pencil or -//! Eraser they set the brush's Opacity (⇧: Flow); with the Gradient or Paint Bucket their +//! Eraser they set the brush's Opacity (⇧: Flow; swapped while airbrush build-up is on); with the Gradient or Paint Bucket their //! Opacity; with tools that have no opacity of their own (Move, the selection tools, Type…) the //! active layer's Opacity (⇧: Fill). Tools whose number is a strength or exposure are left alone. @@ -100,6 +100,11 @@ pub fn press(app: &mut PhotocraftApp, d: u8, shift: bool, now: f64) { fn apply(app: &mut PhotocraftApp, percent: f32, shift: bool) { let v = percent / 100.0; + // With airbrush build-up on, the keys swap roles, as in Photoshop: plain digits set Flow, ⇧ digits Opacity. + // A Pencil- or Block-mode Eraser has no build-up (its options bar disables Flow and the airbrush). + let tool = app.ui.tool; + let build_up = app.session.tools.brush.build_up && !crate::eraser_ui::pencil_mode(app, tool) && !crate::eraser_ui::block_mode(app, tool); + let shift = shift ^ (build_up && matches!(target(tool), Target::Brush { flow: true })); match (target(app.ui.tool), shift) { (Target::Brush { .. }, false) => { let _ = app.run("tools.setBrush", json!({ "brush": { "opacity": v } })); @@ -169,6 +174,33 @@ mod tests { assert_eq!(p.session.tools.brush.flow, 1.0); } + #[test] + fn with_build_up_digits_set_flow_and_shift_digits_set_opacity() { + let mut a = app(Tool::Brush); + a.session.tools.brush.build_up = true; + press(&mut a, 3, false, 0.0); + assert!((a.session.tools.brush.flow - 0.3).abs() < 1e-6); + assert_eq!(a.session.tools.brush.opacity, 1.0, "opacity is untouched"); + press(&mut a, 6, true, 5000.0); + assert!((a.session.tools.brush.opacity - 0.6).abs() < 1e-6); + assert!((a.session.tools.brush.flow - 0.3).abs() < 1e-6); + // The Pencil has no flow, so build-up changes nothing for it. + let mut p = app(Tool::Pencil); + p.session.tools.brush.build_up = true; + press(&mut p, 4, false, 0.0); + assert!((p.session.tools.brush.opacity - 0.4).abs() < 1e-6); + assert_eq!(p.session.tools.brush.flow, 1.0); + // The Eraser swaps too, except in Pencil mode, which has no build-up. + let mut e = app(Tool::Eraser); + e.session.tools.brush.build_up = true; + press(&mut e, 2, false, 0.0); + assert!((e.session.tools.brush.flow - 0.2).abs() < 1e-6); + e.ui.tool_options.eraser_mode = "pencil".into(); + press(&mut e, 7, false, 5000.0); + assert!((e.session.tools.brush.opacity - 0.7).abs() < 1e-6); + assert!((e.session.tools.brush.flow - 0.2).abs() < 1e-6); + } + #[test] fn other_tools_set_the_layer_or_their_own_opacity() { let mut a = app(Tool::Move); diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs index a4d93ca057..e2b1603061 100644 --- a/crates/ui-egui/src/open_prompt.rs +++ b/crates/ui-egui/src/open_prompt.rs @@ -169,6 +169,8 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result) -> Result index, }; app.session.set_active(index); - crate::jobs_ui::after_open(app, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice)); + crate::jobs_ui::after_open(app, document, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice))?; Ok(json!({"document": index, "action": action})) } diff --git a/crates/ui-egui/src/page_keys.rs b/crates/ui-egui/src/page_keys.rs new file mode 100644 index 0000000000..7f00cbc867 --- /dev/null +++ b/crates/ui-egui/src/page_keys.rs @@ -0,0 +1,187 @@ +//! Photoshop's page keys scroll the canvas (#8057): Page Up and Page Down a screen up or down, +//! ⌘/Ctrl turns them sideways (⌘ Page Up left, ⌘ Page Down right), and ⇧ makes the step ten +//! screen points. Home and End show the document's top-left and bottom-right corners. The zoom +//! stays as it is. A binding of the user's on the same keys has already run and taken them. + +use egui::{Event, Key, Modifiers, Vec2, vec2}; + +use crate::PhotocraftApp; + +/// Screen points a ⇧ page key scrolls. +pub const SMALL_STEP: f32 = 10.0; + +/// What a page key does to the view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PageKey { + /// Scroll by this many screen points (positive: show what is below or to the right). + Scroll(Vec2), + /// Show the document's top-left corner (Home) or its bottom-right one (End). + Corner { end: bool }, +} + +/// The page key `key` with `mods` held, in a canvas of `size` points. `None` for other keys, ⌥, +/// or ⇧ on Home and End. +pub fn page_key(key: Key, mods: Modifiers, size: Vec2) -> Option { + if mods.alt { + return None; + } + let sign = match key { + Key::PageUp => -1.0, + Key::PageDown => 1.0, + Key::Home | Key::End if !mods.shift && !mods.command => return Some(PageKey::Corner { end: key == Key::End }), + _ => return None, + }; + let sideways = mods.command; + let step = match (mods.shift, sideways) { + (true, _) => SMALL_STEP, + (false, true) => size.x, + (false, false) => size.y, + }; + Some(PageKey::Scroll(if sideways { vec2(sign * step, 0.0) } else { vec2(0.0, sign * step) })) +} + +/// Consumes this frame's page keys and moves the active document's view. True when one ran. +pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + let Some(i) = app.session.active_index() else { return false }; + let rect = crate::rulers::content_rect(app, app.last_canvas_rect); + let size = rect.size(); + let presses: Vec = ctx.input_mut(|input| { + let mut out = Vec::new(); + input.events.retain(|e| match e { + Event::Key { key, pressed: true, modifiers, .. } => match page_key(*key, *modifiers, size) { + Some(p) => { + out.push(p); + false + } + None => true, + }, + _ => true, + }); + out + }); + if presses.is_empty() { + return false; + } + app.sync_views(); + let Some(doc_size) = app.session.active().map(|st| [st.doc.size.width as f32, st.doc.size.height as f32]) else { return false }; + let (ppp, aspect, flip, overscroll) = (app.ppp, app.ui.view.display_aspect(), app.ui.view.flip_horizontal, app.session.prefs().tools.overscroll); + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let Some(view) = app.ui.views.get_mut(i) else { return false }; + let zoom = view.zoom / ppp; + if !zoom.is_finite() || zoom <= 0.0 { + return false; + } + view.fit_pending = false; + view.fill_pending = false; + for p in presses { + match p { + PageKey::Scroll(d) => { + let xf = crate::canvas::ViewXform { rect, zoom, center: view.center, flip, rotation: view.rotation, aspect }; + let d = xf.unmap_vec(d) / zoom; + if d.is_finite() { + view.center[0] += d.x; + view.center[1] += d.y; + } + } + PageKey::Corner { end } => { + // The document corner goes to the canvas corner the document runs on from: the + // top-left one in an upright view, another when it is rotated or flipped. + let xf = crate::canvas::ViewXform { rect, zoom, center: view.center, flip, rotation: view.rotation, aspect }; + let [w, h] = doc_size; + let (corner, inward) = if end { ([w, h], vec2(-1.0, -1.0)) } else { ([0.0, 0.0], vec2(1.0, 1.0)) }; + let dir = xf.map_vec(inward); + let at = vec2(-dir.x.signum() * size.x / 2.0, -dir.y.signum() * size.y / 2.0); + let off = xf.unmap_vec(at) / zoom; + if off.is_finite() { + view.center = [corner[0] - off.x, corner[1] - off.y]; + } + } + } + } + // Overscroll off: the view stops at the edge, as the scrollbars do. + if !overscroll { + crate::scrollbars::clamp_view_with_aspect(view, size, ppp, aspect); + } + ctx.request_repaint(); + true +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn page_keys_map_to_photoshops_scrolls() { + let size = vec2(800.0, 600.0); + let (none, shift, cmd) = (Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND); + assert_eq!(page_key(Key::PageDown, none, size), Some(PageKey::Scroll(vec2(0.0, 600.0)))); + assert_eq!(page_key(Key::PageUp, none, size), Some(PageKey::Scroll(vec2(0.0, -600.0)))); + assert_eq!(page_key(Key::PageDown, shift, size), Some(PageKey::Scroll(vec2(0.0, SMALL_STEP)))); + assert_eq!(page_key(Key::PageUp, cmd, size), Some(PageKey::Scroll(vec2(-800.0, 0.0)))); + assert_eq!(page_key(Key::PageDown, cmd | shift, size), Some(PageKey::Scroll(vec2(SMALL_STEP, 0.0)))); + assert_eq!(page_key(Key::Home, none, size), Some(PageKey::Corner { end: false })); + assert_eq!(page_key(Key::End, none, size), Some(PageKey::Corner { end: true })); + assert_eq!(page_key(Key::PageDown, Modifiers::ALT, size), None); + assert_eq!(page_key(Key::Home, shift, size), None); + assert_eq!(page_key(Key::ArrowDown, none, size), None); + } + + /// #8057: through the app's key handling, Page Down scrolls a zoomed-in document a screen + /// down at the same zoom, ⇧ a small step, ⌘ sideways; Home and End jump to the corners. + #[test] + fn page_keys_scroll_the_zoomed_canvas() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + app.sync_views(); + app.ppp = 1.0; + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.fill_pending) = (4.0, [1000.0, 1000.0], false, false); + let size = crate::rulers::content_rect(&app, app.last_canvas_rect).size(); + let press = |app: &mut PhotocraftApp, key: Key, modifiers: Modifiers| { + let raw = egui::RawInput { + events: vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + app.ui.views[0].center + }; + let near = |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3; + let c = press(&mut app, Key::PageDown, Modifiers::NONE); + assert!(near(c, [1000.0, 1000.0 + size.y / 4.0]), "a screen down: {c:?}"); + assert_eq!(app.ui.views[0].zoom, 4.0, "the zoom stays"); + let c = press(&mut app, Key::PageUp, Modifiers::NONE); + assert!(near(c, [1000.0, 1000.0]), "and back: {c:?}"); + let c = press(&mut app, Key::PageDown, Modifiers::SHIFT); + assert!(near(c, [1000.0, 1000.0 + SMALL_STEP / 4.0]), "a small step: {c:?}"); + let c = press(&mut app, Key::PageUp, Modifiers::COMMAND); + assert!(near(c, [1000.0 - size.x / 4.0, 1000.0 + SMALL_STEP / 4.0]), "a screen left: {c:?}"); + let c = press(&mut app, Key::Home, Modifiers::NONE); + assert!(near(c, [size.x / 8.0, size.y / 8.0]), "the top-left corner: {c:?}"); + let c = press(&mut app, Key::End, Modifiers::NONE); + assert!(near(c, [2000.0 - size.x / 8.0, 2000.0 - size.y / 8.0]), "the bottom-right corner: {c:?}"); + + // Rotated a quarter turn or flipped, the corner still lands on a canvas corner with the + // document running into the canvas from it. + let rect = crate::rulers::content_rect(&app, app.last_canvas_rect); + for (rotation, flip) in [(90.0, false), (0.0, true), (-90.0, true)] { + app.ui.views[0].rotation = rotation; + app.ui.view.flip_horizontal = flip; + for (key, corner, inward) in [(Key::Home, [0.0, 0.0], 1.0), (Key::End, [2000.0, 2000.0], -1.0)] { + let center = press(&mut app, key, Modifiers::NONE); + let xf = crate::canvas::ViewXform { rect, zoom: 4.0, center, flip, rotation, aspect: 1.0 }; + let at = xf.to_screen(corner[0], corner[1]); + let on_corner = [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()].iter().any(|c| c.distance(at) < 1e-2); + assert!(on_corner, "{key:?} at {rotation}° flip {flip}: {at:?} in {rect:?}"); + assert!( + rect.contains(xf.to_screen(corner[0] + inward, corner[1] + inward)), + "{key:?} at {rotation}° flip {flip}: the document runs into the canvas" + ); + } + } + } +} diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 6a19c9ff77..84695522d1 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -30,7 +30,13 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { let current = app.session.tools.brush.clone(); save_brush(app, app.ui.brush_tool, current.clone()); app.session.tools.brush = match app.ui.tool_brushes.iter().find(|(t, _)| *t == tool) { - Some((_, b)) => session_fields(¤t, b), + Some((_, b)) => { + // A brush remembered from the previous run holds its stored tips as references: + // load their full bitmaps (a missing one keeps painting with its preview). + let mut b = session_fields(¤t, b); + let _ = app.session.load_brush_tips(&mut b); + b + } // A tool seen for the first time takes what the brush has, at Photoshop's smoothing. None => BrushSettings { smoothing: first_smoothing(), ..current }, }; @@ -86,11 +92,17 @@ pub struct Buttons { pub clicked: bool, } -/// Tools whose right-click opens the Brush Preset picker (those with the options-bar brush chip). +/// Tools with the options-bar brush chip, which opens the Brush Preset picker. pub fn has_brush_picker(tool: Tool) -> bool { tool.is_brushlike() || tool == Tool::QuickSelection } +/// Tools whose canvas right-click opens the Brush Preset picker: the painting tools. The Quick +/// Selection tool has the picker too, but its right-click opens the selection menu (#7268). +pub fn right_click_picker(tool: Tool) -> bool { + has_brush_picker(tool) && !crate::canvas_tool_menu::applies(tool) +} + /// Does a right-button drag with `tool` erase (rather than open the picker)? pub fn right_erases(app: &PhotocraftApp, tool: Tool) -> bool { matches!(tool, Tool::Brush | Tool::Eraser) && app.session.prefs().tools.right_click_with_painting_tools == RightClickPaint::Erase @@ -130,7 +142,7 @@ pub fn canvas_buttons(app: &mut PhotocraftApp, response: &Response, tool: Tool) if right_click && !erase && !layer_menu - && has_brush_picker(tool) + && right_click_picker(tool) && let Some(p) = response.interact_pointer_pos() { crate::brush_picker::open(&mut app.ui, &response.ctx, [p.x, p.y]); @@ -160,7 +172,7 @@ pub fn pointer_secondary(app: &mut PhotocraftApp, ctx: &egui::Context, down: boo app.secondary_erase = down; return true; } - if down && has_brush_picker(tool) { + if down && right_click_picker(tool) { crate::brush_picker::open(&mut app.ui, ctx, at); } false diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index f68c3e46e7..6ac0d78a9e 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -447,7 +447,7 @@ pub(crate) fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { let output = if layout.root.is_some() { let mut panel = egui::Panel::right("shared-panel-dock") .default_size(if t.pro { 290.0 } else { 300.0 }) - .size_range(crate::panels::DOCK_WIDTH) + .size_range(crate::panels::dock_width_range(ui.ctx().content_rect().width())) .resizable(!locked) .frame(egui::Frame::NONE.fill(t.card)); if let Some(width) = requested_width { @@ -855,11 +855,15 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "history" => { + crate::history_menu_ui::panel_menu(app, ui); + ui.separator(); + } "brushSettings" | "brush_settings" => { crate::brush_panel::panel_menu(app, ui); ui.separator(); } - "actions" => { + "actions" | "Actions" => { crate::actions::panel_menu(app, ui); ui.separator(); } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 193ff2e6b8..2c25810a66 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -561,7 +561,9 @@ fn legacy_dock_width_is_consumed_by_custom_renderer_and_clamped_to_existing_rang app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); let mut h = harness(app, vec2(1280.0, 800.0)); let ctx = h.ctx.clone(); - for (requested, expected) in [(410.0, 410.0), (900.0, 520.0), (20.0, 250.0)] { + // A 1280-point window: the dock may take all but 400 points (#7896), so 700 is no longer + // cut to 520. + for (requested, expected) in [(410.0, 410.0), (700.0, 700.0), (900.0, 880.0), (20.0, 250.0)] { assert_eq!(control_set(h.state_mut(), &ctx, json!({"dockWidth":requested}))["ok"], true); h.run_steps(2); let state = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap(); @@ -673,6 +675,25 @@ fn customized_panel_menus_and_rail_remain_reachable_after_float_and_restore() { assert!(menu.query_all_by_label("New Swatch…").count() > 0); } +#[test] +fn actions_panel_menu_includes_load_and_save_actions() { + let mut ready = false; + let mut menu = Harness::builder().build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if !ready { + PhotocraftApp::setup_context(ui.ctx(), crate::theme::ThemeKind::ProMedium); + ready = true; + } + panel_menu(app, ui, "actions"); + }, + self::app(), + ); + menu.run_steps(2); + assert!(menu.query_all_by_label("Load Actions…").count() > 0); + assert!(menu.query_all_by_label("Save Actions…").count() > 0); + assert!(menu.query_all_by_label("New Action…").count() > 0); +} + #[test] fn custom_workspace_saves_shared_layout_and_timeline_without_embedding_flags() { let mut app = app(); @@ -768,3 +789,13 @@ fn native_tab_reorder_stays_native_until_drag_crosses_the_strip() { let channels = tabs.iter().position(|(_, label)| *label == "Channels").unwrap(); assert!(channels < layers, "upstream native reorder remains effective"); } + +/// #7896: the dock's widest is the window less what the toolbar and the canvas keep, never +/// below the old 520 points, so a wide window allows a two-column dock. +#[test] +fn the_dock_can_be_as_wide_as_the_window_allows() { + for (window, max) in [(2560.0, 2160.0), (1280.0, 880.0), (800.0, 520.0), (f32::NAN, 520.0)] { + let range = crate::panels::dock_width_range(window); + assert_eq!((*range.start(), *range.end()), (crate::panels::DOCK_MIN_WIDTH, max), "{window}"); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 18cef34c98..b316669258 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3,10 +3,11 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_color::BlendMode; use photocraft_doc::{Layer, LayerContent, LayerId}; +use photocraft_engine::history_source_cmds::HistorySource; use serde_json::{Value, json}; use crate::mask_thumbs_ui::MaskKind; -use crate::state::Tool; +use crate::state::{ColorPanelSliderMode, Tool}; use crate::theme::{self, Tokens}; use crate::{PhotocraftApp, icons, widgets}; @@ -78,6 +79,17 @@ fn visible_sections(hidden: &[String]) -> Vec)>> { sections } +/// A primary-button gesture started on this tool; open only after it leaves its hit area. +/// Using pointer origin rather than `Response::dragged` also works for click-only icon buttons. +fn tool_flyout_drag_open(button: Rect, origin: Option, pointer: Option, primary_down: bool) -> bool { + primary_down && origin.is_some_and(|p| button.contains(p)) && pointer.is_some_and(|p| !button.contains(p)) +} + +/// A toolbar press cannot click a flyout row in egui because the press began elsewhere. +fn tool_flyout_release(row: Rect, pointer: Option, released: bool, from_button: bool) -> bool { + released && from_button && pointer.is_some_and(|p| row.contains(p)) +} + pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) }; @@ -178,7 +190,7 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::tool_memory::select(app, tool); let _ = app.run("view.actualPixels", json!({})); } - // Right-click or long-press opens the flyout (Photoshop). + // Right-click, long-press, or press-drag away opens the flyout (Photoshop). let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); if slot.len() > 1 @@ -188,6 +200,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); } let long_press = held_for.is_some_and(|seconds| seconds >= 0.35); + let drag_open = ui + .input(|i| tool_flyout_drag_open(resp.rect, i.pointer.press_origin(), i.pointer.hover_pos(), i.pointer.primary_down())); // Clicking the button of its open flyout again, with either button, closes // it (#4932); the release that ends the long press that opened it doesn't. let open_here = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_some_and(|(k, _)| k == key); @@ -195,10 +209,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && (resp.secondary_clicked() || (resp.clicked() && ui.data(|d| d.get_temp::(held_id)) != Some(key))); if reclicked { ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); - } else if slot.len() > 1 && (resp.secondary_clicked() || long_press) { + } else if slot.len() > 1 && (resp.secondary_clicked() || long_press || drag_open) { ui.data_mut(|d| { d.insert_temp(flyout_id, (key, resp.rect)); - if long_press { + if long_press || drag_open { d.insert_temp(held_id, key); } }); @@ -266,7 +280,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { t.text_dim, ); } - if ir.clicked() { + let drag_release = tool_flyout_release( + r, + ui.input(|i| i.pointer.hover_pos()), + ui.input(|i| i.pointer.primary_released()), + ui.data(|d| d.get_temp::(held_id)) == Some(key), + ); + if ir.clicked() || drag_release { crate::tool_memory::select(app, item); ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } @@ -294,7 +314,9 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { reveal_focused_control(ui); }); ui.add_space(if t.pro { 8.0 } else { 14.0 }); - color_chips(app, ui, double); + // Centred in the tool column as it is before the tools scroll: a scroll bar widens the + // UI it is in, and the chips moved over by its width (#7316). + color_chips(app, ui, double, full.width()); ui.add_space(8.0); let layout = if double { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; @@ -416,7 +438,7 @@ fn chips_height(pro: bool, studio: bool, wide: bool) -> f32 { /// Photoshop's foreground / background colour chips: the Default Colors (D) and Switch Colors (X) /// icons above them, the foreground chip over the background one, all centred in the toolbar /// column and kept inside it. -fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { +fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool, column: f32) { let t = Tokens::get(ui.ctx()); // Studio themes: round chips stacked vertically, the controls below them. let studio = t.round_chips; @@ -424,7 +446,7 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { // With two tool columns (a short window) the round chips lie side by side, which saves height. let wide = studio && two_columns; let group = if studio && !wide { chip } else { chip + step }; - let w = ui.available_width().max(group); + let w = column.max(group); let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro, studio, wide)), Sense::hover()); let left = (rect.left() + (w - group) / 2.0).round(); let (fg, bg) = if wide { @@ -594,8 +616,8 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let gap = egui::Rect::from_x_y_ranges(gap_l.max(full.left())..=gap_r.min(full.right()).max(gap_l), full.y_range()); ui.ctx().data_mut(|d| d.insert_temp(crate::titlebar::modal_drag_area_id(), gap)); let drag = ui.interact(gap, ui.id().with("titledrag"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + if drag.drag_started_by(egui::PointerButton::Primary) { + crate::titlebar::start_os_move(ui.ctx()); } if drag.double_clicked() { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); @@ -648,16 +670,8 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let ui = &mut group_ui; ui.spacing_mut().item_spacing.x = 6.0; let mut ws = app.ui.workspace.clone(); - let opts = [ - // Keys stay English (`apply_workspace` matches them); the dropdown - // shows the labels in the UI language. - ("Essentials".to_string(), tl!("Essentials")), - ("Photography".to_string(), tl!("Photography")), - ("Painting".to_string(), tl!("Painting")), - ("Graphic and Web".to_string(), tl!("Graphic and Web")), - ("Pixel Art".to_string(), tl!("Pixel Art")), - ("Motion".to_string(), tl!("Motion")), - ]; + let options = crate::workspace_ui::switcher_options(app); + let opts: Vec<(String, &str)> = options.iter().map(|(k, l)| (k.clone(), l.as_str())).collect(); // A narrow bar drops what is also in a menu, the theme toggle first // (Preferences), then search (Edit › Search), and narrows the switcher // (Window › Workspace) before anything runs over. @@ -1164,6 +1178,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } ui.spacing_mut().item_spacing.x = 8.0; widgets::vline(ui, 22.0); + let (units, size) = fixed_size_units(app); let o = &mut app.ui.tool_options; opt_label(ui, tl!("Feather")); widgets::value_field(ui, &mut o.feather, 0.0..=1000.0, "px", 62.0); @@ -1182,20 +1197,32 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { (o.marquee_width, o.marquee_height) = if o.marquee_style == "fixedSize" { (64.0, 64.0) } else { (1.0, 1.0) }; } let fixed = sized && o.marquee_style != "normal"; - let (unit, range) = if o.marquee_style == "fixedSize" { ("px", 1.0..=300_000.0) } else { ("", 0.001..=999.0) }; + let fixed_size = o.marquee_style == "fixedSize"; + // Fixed Size shows its pixels in the unit picked by right-clicking Width or Height (#7910). + let units = widgets::FieldUnits { unit: photocraft_engine::prefs::Unit::parse(&o.marquee_unit).unwrap_or_default(), ..units }; ui.add_enabled_ui(fixed, |ui| { + let mut picked = None; opt_label(ui, tl!("Width")); - widgets::value_field(ui, &mut o.marquee_width, range.clone(), unit, 62.0); + if fixed_size { + picked = fixed_size_field(ui, &mut o.marquee_width, units, size[0]); + } else { + widgets::value_field(ui, &mut o.marquee_width, 0.001..=999.0, "", 62.0); + } if icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked() { std::mem::swap(&mut o.marquee_width, &mut o.marquee_height); } opt_label(ui, tl!("Height")); - widgets::value_field(ui, &mut o.marquee_height, range, unit, 62.0); + if fixed_size { + picked = fixed_size_field(ui, &mut o.marquee_height, units, size[1]).or(picked); + } else { + widgets::value_field(ui, &mut o.marquee_height, 0.001..=999.0, "", 62.0); + } + if let Some(name) = picked { + o.marquee_unit = name.into(); + } }); widgets::vline(ui, 22.0); - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); } Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso if t.pro => { selection_mode_buttons(app, ui); @@ -1220,9 +1247,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } widgets::vline(ui, 22.0); } - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); if crate::lasso_ui::active(app) || (app.ui.tool == Tool::PolygonLasso && !app.ui.polygon.is_empty()) { hint( ui, @@ -1257,9 +1282,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let r = app.run("select.subject", json!({})); crate::retouch_ui::report_smart_result(app, r, tl!("No subject found — try Object Selection or the Lasso")); } - if widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked() { - let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); - } + select_and_mask_button(app, ui); } Tool::PaintBucket if t.pro => { opt_label(ui, tl!("Fill")); @@ -1331,6 +1354,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919). crate::crop_presets::dropdown(app, ui); let o = &mut app.ui.tool_options; + let mut ratio_base = None; if o.crop_ratio == crate::crop_size::WHR { crate::crop_size::fields(o, ui); } else { @@ -1338,15 +1362,27 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Original Ratio shows the document's numbers, as Photoshop does. let (mut rw, mut rh) = crate::crop_size::custom_ratio_fields(o, doc_size); let decimals = crate::crop_size::RATIO_DECIMALS; - let cw = widgets::value_field_prec(ui, &mut rw, 0.0..=99_999.0, "", 54.0, decimals).changed(); + let fw = widgets::value_field_prec(ui, &mut rw, 0.0..=99_999.0, "", 54.0, decimals); let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); - let ch = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals).changed(); + let fh = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals); + let (cw, ch) = (fw.changed(), fh.changed()); if swap { std::mem::swap(&mut rw, &mut rh); } if cw || ch || swap { crate::crop_size::set_custom_ratio_fields(o, rw, rh); } + // Every ratio typed or scrubbed in one edit fits from the frame the edit + // started with: 1:3 on the way to 16:3 mustn't shrink it first (#7752). + let base_id = egui::Id::new("crop-ratio-edit-base"); + if cw || ch { + ratio_base = ui.data(|d| d.get_temp::<[f64; 4]>(base_id)).or(app.ui.crop_rect); + } + if ![&fw, &fh].iter().any(|f| f.has_focus() || f.dragged()) { + ui.data_mut(|d| d.remove::<[f64; 4]>(base_id)); + } else if let Some(base) = ratio_base { + ui.data_mut(|d| d.insert_temp(base_id, base)); + } } widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() { @@ -1371,7 +1407,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 150.0, ); if crate::crop_ui::preset_ratio(app) != before_ratio { - crate::crop_ui::apply_ratio(app); + match ratio_base { + Some(base) => crate::crop_ui::apply_ratio_from(app, base), + None => crate::crop_ui::apply_ratio(app), + } } if pick_shield_color { crate::crop_shield::pick_custom_color(app); @@ -1408,34 +1447,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); widgets::checkbox(ui, &mut o.move_show_transform, tl!("Show Transform Controls")); widgets::vline(ui, 22.0); - ui.spacing_mut().item_spacing.x = 0.0; - for (icon, tip, cmd) in [ - ("panels-top-left", tl!("Align left edges"), "layer.align.leftEdges"), - ("app-window", tl!("Align horizontal centers"), "layer.align.horizontalCenters"), - ("panel-right", tl!("Align right edges"), "layer.align.rightEdges"), - ] { - let on = app.session.is_enabled(cmd); - if ui.add_enabled_ui(on, |ui| icons::button(ui, icon, 24.0, false, tip)).inner.clicked() { - let _ = app.run(cmd, json!({})); - } - } - ui.add_space(6.0); - let more = icons::button(ui, "ellipsis", 24.0, false, tl!("Align and distribute")); - egui::Popup::menu(&more).show(|ui| { - ui.set_min_width(200.0); - let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| { - ui.label(egui::RichText::new(tl!(&title)).small().color(t.text_dim)); - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) { - if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(tl!(c.label))).clicked() { - let _ = app.run(c.id, json!({})); - ui.close(); - } - } - }; - section(ui, tl!("Align"), "layer.align."); - ui.separator(); - section(ui, tl!("Distribute"), "layer.distribute."); - }); + crate::align_bar::options(app, ui); } Tool::RectMarquee | Tool::EllipseMarquee => hint( ui, @@ -1462,9 +1474,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // turned on outside the Pro themes. widgets::checkbox(ui, &mut app.ui.tool_options.move_show_transform, tl!("Show Transform Controls")); hint(ui, tl!("Drag to move the active layer")); + widgets::vline(ui, 22.0); + crate::align_bar::options(app, ui); } Tool::Eyedropper => crate::eyedropper_ui::options(app, ui), Tool::Zoom => { + // Resize Windows to Fit is Preferences › Zoom Resizes Windows itself, as in + // Photoshop: one setting, shown in both places (zoom_window_fit.rs). + let mut fit = app.session.prefs().general.zoom_resizes_windows; + if widgets::checkbox(ui, &mut fit, tl!("Resize Windows to Fit")).changed() { + app.session.prefs.edit(|p| p.general.zoom_resizes_windows = fit); + } widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, tl!("Scrubby Zoom")); hint( ui, @@ -1477,11 +1497,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; } if widgets::secondary_button(ui, "100%", 0.0).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; + app.ui.views[i].fit_pending = false; + app.ui.views[i].fill_pending = false; } } Tool::RotateView => { @@ -1507,6 +1533,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].zoom = 1.0; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; app.ui.views[i].fill_pending = false; } @@ -1514,12 +1542,16 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { && let Some(i) = app.session.active_index() { app.ui.views[i].fit_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fill_pending = false; } if widgets::secondary_button(ui, tl!("Fill Screen"), 0.0).clicked() && let Some(i) = app.session.active_index() { app.ui.views[i].fill_pending = true; + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; } } @@ -1636,6 +1668,40 @@ fn opt_label(ui: &mut egui::Ui, s: &str) { widgets::scrub_option_label(ui, &label); } +/// The largest Fixed Size side, in pixels. +const FIXED_SIZE_MAX_PX: f64 = 300_000.0; + +/// The resolution Fixed Size's units convert at (the active document's, 72 ppi without one) and +/// the document's size in pixels, which a percentage is of. The unit is the caller's. +fn fixed_size_units(app: &PhotocraftApp) -> (widgets::FieldUnits, [f64; 2]) { + let ppi = app.session.prefs().units_and_rulers.point_size.per_inch(); + let (dpi, size) = app + .session + .active() + .map_or((72.0, [1.0, 1.0]), |d| (f64::from(d.doc.resolution_dpi.max(1.0)), [f64::from(d.doc.size.width), f64::from(d.doc.size.height)])); + (widgets::FieldUnits { unit: photocraft_engine::prefs::Unit::Pixels, dpi, ppi }, size) +} + +/// One of Fixed Size's Width and Height (#7910): `px` shown in `units.unit` (a typed unit converts), +/// still stored in pixels. A right-click lists the units, as on the rulers; returns the one picked. +fn fixed_size_field(ui: &mut egui::Ui, px: &mut f32, units: widgets::FieldUnits, extent: f64) -> Option<&'static str> { + let widgets::FieldUnits { unit, dpi, ppi } = units; + let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32; + let before = to_unit(f64::from(*px)); + let mut v = before; + let places = unit.decimals() as u32; + let resp = widgets::value_field_extent(ui, &mut v, to_unit(1.0)..=to_unit(FIXED_SIZE_MAX_PX), unit.suffix(), 62.0, places, units, extent); + // Leaving the field re-reads the rounded text it showed (100 px at 72 ppi reads back as + // 1.389 in); that isn't an edit, so the pixels stay exact. + let shown = |x: f32| (f64::from(x) * 10f64.powi(places as i32)).round(); + if resp.changed() && shown(v) != shown(before) { + *px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, FIXED_SIZE_MAX_PX) as f32; + } + let mut picked = None; + resp.context_menu(|ui| picked = crate::rulers::unit_menu(ui, unit)); + picked +} + fn percent_field(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive, width: f32) { opt_label(ui, label); let mut percent = *value * 100.0; @@ -1684,6 +1750,8 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .changed() { app.ui.views[i].zoom = crate::zoom_levels::clamp(pct / 100.0, app.ui.views[i].doc_size); + app.ui.views[i].zoom_animating = false; + app.ui.views[i].zoom_anchor = None; app.ui.views[i].fit_pending = false; } widgets::vline(ui, 16.0); @@ -1771,8 +1839,18 @@ pub fn right_dock(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::dock::persist(app, ui.ctx()); } -/// The dock's width range (points). It is the right dock, on the left in a right-to-left UI. -pub(crate) const DOCK_WIDTH: std::ops::RangeInclusive = 250.0..=520.0; +/// The dock's narrowest width (points). It is the right dock, on the left in a right-to-left UI. +pub(crate) const DOCK_MIN_WIDTH: f32 = 250.0; +/// What the dock leaves the toolbar and the canvas when it is widest (points). +const DOCK_LEAVES: f32 = 400.0; + +/// The dock's width range (points) in a window `window_width` wide: from [`DOCK_MIN_WIDTH`] to +/// everything but [`DOCK_LEAVES`], and never capped below the old 520, so two-column layouts can +/// be as wide as the window allows (#7896). +pub(crate) fn dock_width_range(window_width: f32) -> std::ops::RangeInclusive { + let max = if window_width.is_finite() { (window_width - DOCK_LEAVES).max(520.0) } else { 520.0 }; + DOCK_MIN_WIDTH..=max +} pub(crate) fn dock_width_id() -> egui::Id { egui::Id::new("dock-width-request") @@ -1780,7 +1858,8 @@ pub(crate) fn dock_width_id() -> egui::Id { /// Set the right dock's width on the next frame (`ui.set {dockWidth}`); clamped to its range. pub fn request_dock_width(ctx: &egui::Context, w: f32) { - let w = if w.is_finite() { w.clamp(*DOCK_WIDTH.start(), *DOCK_WIDTH.end()) } else { *DOCK_WIDTH.start() }; + let range = dock_width_range(ctx.content_rect().width()); + let w = if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { *range.start() }; ctx.data_mut(|d| d.insert_temp(dock_width_id(), w)); } @@ -1803,7 +1882,10 @@ fn dock_panels(app: &mut PhotocraftApp, ui: &mut egui::Ui, p: &crate::state::Pan return; } let margin = if t.pro { 2 } else { 8 }; - let mut panel = crate::direction::end_panel("dock").resizable(true).default_size(if t.pro { 290.0 } else { 300.0 }).size_range(DOCK_WIDTH); + let mut panel = crate::direction::end_panel("dock") + .resizable(true) + .default_size(if t.pro { 290.0 } else { 300.0 }) + .size_range(dock_width_range(ui.ctx().content_rect().width())); if let Some(w) = ui.ctx().data_mut(|d| d.remove_temp::(dock_width_id())) { panel = panel.exact_size(w); } @@ -1820,7 +1902,14 @@ pub(crate) fn dock_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, group: crate match (group, tab) { (Group::Color, 2) => crate::preset_panels::gradients_panel(app, ui), (Group::Color, 3) => crate::preset_panels::patterns_panel(app, ui), - (Group::Color, 0) if pro => color_field(app, ui), + (Group::Color, 0) if pro => { + color_field(app, ui); + if app.ui.color_panel.slider_mode == ColorPanelSliderMode::Rgb { + ui.add_space(8.0); + rgb_sliders(app, ui); + } + color_slider_mode_row(app, ui); + } (Group::Color, _) if pro => crate::swatches_ui::panel(app, ui), (Group::Color, 0) => crate::swatches_ui::panel(app, ui), (Group::Color, _) => color_picker(app, ui), @@ -1848,6 +1937,7 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; }; let (rev, size, origin) = (st.revision, st.doc.size, st.doc.ruler_origin); + let cmyk_space = photocraft_compose::cmyk_space(&st.doc); // Positions and sizes in the ruler unit (Preferences › Units & Rulers). let units = app.session.prefs().units_and_rulers.clone(); let dpi = st.doc.resolution_dpi as f64; @@ -1893,9 +1983,10 @@ fn info_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { row(&mut cols[0], "R:", rgba.map_or_else(dash, |c| q(c[0]).to_string())); row(&mut cols[0], "G:", rgba.map_or_else(dash, |c| q(c[1]).to_string())); row(&mut cols[0], "B:", rgba.map_or_else(dash, |c| q(c[2]).to_string())); - // Naive device CMYK (no ICC profile yet), in percent like Photoshop's readout. The Color - // Sampler readout uses the same conversion (`sampler_cmds::device_cmyk`). - let cmyk = rgba.map(|c| photocraft_engine::sampler_cmds::device_cmyk(c[0], c[1], c[2])); + // Colour-managed CMYK in percent like Photoshop's readout, through the same conversion as + // the Color Picker (#8101) and the Color Sampler readout (`sampler_cmds::device_cmyk`). + let cmyk = + rgba.map(|c| photocraft_color::convert::with_cmyk_space(cmyk_space.as_ref(), || photocraft_engine::sampler_cmds::device_cmyk(c[0], c[1], c[2]))); for (i, k) in ["C:", "M:", "Y:", "K:"].iter().enumerate() { row(&mut cols[1], k, cmyk.map_or_else(dash, |c| format!("{}%", c[i]))); } @@ -2025,6 +2116,8 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { let d = (pp - rect.min) / s; app.ui.views[idx].center = [d.x.clamp(0.0, size.width as f32), d.y.clamp(0.0, size.height as f32)]; + app.ui.views[idx].zoom_animating = false; + app.ui.views[idx].zoom_anchor = None; app.ui.views[idx].fit_pending = false; } if command && resp.hovered() { @@ -2044,17 +2137,21 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let next = crate::zoom_levels::clamp(view.zoom * factor, view.doc_size); if next != view.zoom { view.zoom = next; + view.zoom_animating = false; + view.zoom_anchor = None; view.fit_pending = false; } } // Visible-area rectangle. let v = app.ui.views[idx].clone(); let canvas = app.last_canvas_rect; - let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6); + let (zoom, center) = + if v.zoom_animating && app.session.prefs().general.animated_zoom { v.display_camera.unwrap_or((v.zoom, v.center)) } else { (v.zoom, v.center) }; + let point_zoom = (zoom / app.canvas_ppp()).max(1e-6); // Pixel aspect correction shows fewer document columns across the same canvas width. let vw = canvas.width() / point_zoom / app.ui.view.display_aspect() * s; let vh = canvas.height() / point_zoom * s; - let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); + let c = pos2(rect.min.x + center[0] * s, rect.min.y + center[1] * s); let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle); // Photoshop's one row under the preview (#4840): the zoom, typed in percent, then Zoom Out, @@ -2063,18 +2160,22 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(4.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - let set = |app: &mut PhotocraftApp, zoom: f32| { - app.ui.views[idx].zoom = crate::zoom_levels::clamp(zoom, v.doc_size); + let set = |app: &mut PhotocraftApp, zoom: f32, animate: bool| { + let target = crate::zoom_levels::clamp(zoom, v.doc_size); + if target != app.ui.views[idx].zoom { + app.ui.views[idx].zoom = target; + app.ui.views[idx].request_zoom_animation(animate && app.session.prefs().general.animated_zoom); + } app.ui.views[idx].fit_pending = false; }; let mut pct = v.zoom * 100.0; let field = widgets::compact_wrapping_value_field(ui, &mut pct, crate::zoom_levels::percent_range(&app.ui.views[idx]), "%", vec2(64.0, NAV_ROW_H)); if field.changed() { - set(app, pct / 100.0); + set(app, pct / 100.0, false); } let out = crate::brush_picker::named(icons::button(ui, "minus", NAV_ROW_H, false, tl!("Zoom Out")), "Zoom Out"); if out.clicked() { - set(app, crate::zoom_levels::step(v.zoom, -1, v.doc_size)); + set(app, crate::zoom_levels::step(v.zoom, -1, v.doc_size), true); } // The whole zoom range, and always the current zoom: a narrower slider would pull it back. let (lo, hi) = (crate::zoom_levels::min(v.doc_size).min(v.zoom).log2(), crate::zoom_levels::MAX.max(v.zoom).log2()); @@ -2082,11 +2183,11 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let slider_w = (ui.available_width() - NAV_ROW_H - 4.0).max(60.0); let slid = ui.allocate_ui(vec2(slider_w, NAV_ROW_H), |ui| lz.is_finite() && widgets::slider(ui, &mut lz, lo..=hi, None).changed()).inner; if slid { - set(app, 2f32.powf(lz)); + set(app, 2f32.powf(lz), false); } let zin = crate::brush_picker::named(icons::button(ui, "plus", NAV_ROW_H, false, tl!("Zoom In")), "Zoom In"); if zin.clicked() { - set(app, crate::zoom_levels::step(v.zoom, 1, v.doc_size)); + set(app, crate::zoom_levels::step(v.zoom, 1, v.doc_size), true); } }); }); @@ -2144,6 +2245,7 @@ fn navigator_zoom_to(view: &mut crate::state::View, doc_rect: Rect, doc: [u32; 2 view.zoom = crate::zoom_levels::clamp(zoom, view.doc_size); view.center = [r.center().x, r.center().y]; view.fit_pending = false; + view.request_zoom_animation(false); } fn navigator_preview_id() -> egui::Id { @@ -2175,6 +2277,53 @@ mod navigator_wheel_tests { use super::*; use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase}; + /// The painted viewport follows the camera visible on the canvas, including preference changes. + #[test] + fn navigator_viewport_follows_the_displayed_camera_during_animation() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + app.last_canvas_rect = Rect::from_min_size(Pos2::ZERO, vec2(80.0, 60.0)); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + for _ in 0..3 { + frame(&mut app, &ctx, pos2(5.0, 5.0), 0.0); + } + for (animating, enabled, displayed) in [ + (true, true, Some((2.0, [70.0, 40.0]))), + (true, true, Some((3.0, [110.0, 50.0]))), + (false, true, Some((2.0, [70.0, 40.0]))), + (true, false, Some((2.0, [70.0, 40.0]))), + (true, true, None), + ] { + app.run("prefs.set", json!({"values": {"general.animatedZoom": enabled}})).unwrap(); + let v = &mut app.ui.views[0]; + v.zoom = 4.0; + v.center = [140.0, 60.0]; + v.zoom_animating = animating; + v.display_camera = displayed; + let mut out = ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(430.0, 460.0))), ..Default::default() }, |ui| { + navigator(&mut app, ui) + }); + let painted = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.color == Color32::from_rgb(255, 84, 84) => Some(r.rect), + _ => None, + }) + .expect("Navigator viewport outline"); + let (zoom, center) = if animating && enabled { displayed.unwrap_or((4.0, [140.0, 60.0])) } else { (4.0, [140.0, 60.0]) }; + let preview = last_navigator_preview(&ctx).unwrap(); + let scale = preview.width() / 200.0; + let expected_center = preview.min + vec2(center[0], center[1]) * scale; + let expected_size = vec2(80.0 / app.ui.view.display_aspect(), 60.0) * (app.canvas_ppp() / zoom * scale); + assert!((painted.center() - expected_center).length() < 0.01, "{animating}/{enabled}/{displayed:?}: {painted:?}"); + assert!((painted.size() - expected_size).length() < 0.01, "{animating}/{enabled}/{displayed:?}: {painted:?}"); + out.textures_delta.clear(); + } + } + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, pointer: Pos2, wheel: f32) { let mut events = vec![Event::PointerMoved(pointer)]; if wheel != 0.0 { @@ -2279,6 +2428,13 @@ mod navigator_wheel_tests { assert_eq!(app.ui.views[0].zoom, 1.0); let c = app.ui.views[0].center; assert!((c[0] - 100.0).abs() < 1.0 && (c[1] - 50.0).abs() < 1.0, "the view follows the pointer: {c:?}"); + // Finishing direct navigation during an existing zoom must snap to the new target. + let v = &mut app.ui.views[0]; + v.zoom = 2.0; + v.request_zoom_animation(true); + v.display_camera = Some((1.5, c)); + v.zoom_transition = Some(crate::state::ZoomTransition { from: (1.0, c), target: (v.zoom, c), started: 0.0 }); + v.zoom_anchor = Some(([50.0, 25.0], [200.0, 150.0])); // ⌘/Ctrl-drag: the rectangle fills the canvas (`last_canvas_rect`, 800 × 600 pt). drag(&mut app, &ctx, Modifiers::COMMAND, [50.0, 25.0], [100.0, 50.0]); let mut expected = app.ui.views[0].clone(); @@ -2300,6 +2456,8 @@ mod navigator_wheel_tests { (expected.zoom, expected.center) ); assert!(v.zoom > 1.0 && !v.fit_pending); + assert!(!v.zoom_animating && v.zoom_transition.is_none() && v.zoom_anchor.is_none(), "direct navigation cancels the old transition"); + assert_eq!(v.display_camera, Some((v.zoom, v.center)), "the selected rectangle is displayed immediately"); assert!(ctx.data(|d| d.get_temp::(navigator_zoom_drag_id())).is_none(), "the drag is over"); } @@ -2343,7 +2501,31 @@ fn empty(ui: &mut egui::Ui, s: &str) { fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { color_field(app, ui); ui.add_space(8.0); - hsb_sliders(app, ui); + match app.ui.color_panel.slider_mode { + ColorPanelSliderMode::Hsb => hsb_sliders(app, ui), + ColorPanelSliderMode::Rgb => rgb_sliders(app, ui), + } + color_slider_mode_row(app, ui); +} + +/// HSB or RGB sliders under the colour field (#7172). +fn color_slider_mode_row(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + ui.add_space(6.0); + let label = match app.ui.color_panel.slider_mode { + ColorPanelSliderMode::Hsb => tl!("HSB/HSL"), + ColorPanelSliderMode::Rgb => tl!("RGB Color"), + }; + // A press menu, not a combo box: the default layout keeps the Color panel open beside + // adjustment editors, and harness tests pick the combo nearest "3D LUT File" (#7317). + let btn = ui.add_sized([72.0, ui.spacing().interact_size.y], egui::Button::new(label)); + crate::press_menu::show(&btn, |ui, menu| { + if menu.item(ui, tl!("HSB/HSL")) { + app.ui.color_panel.slider_mode = ColorPanelSliderMode::Hsb; + } + if menu.item(ui, tl!("RGB Color")) { + app.ui.color_panel.slider_mode = ColorPanelSliderMode::Rgb; + } + }); } /// Hue, Saturation and Brightness sliders for the foreground colour. @@ -2373,6 +2555,33 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.session.tools.foreground = hsva_srgb(hsva); ui.data_mut(|d| d.insert_temp(key, hsva)); crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui)); + } +} + +/// Red, Green and Blue sliders (0–255) for the colour the panel is editing (#7172). +fn rgb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let bg = app.ui.color_panel.background; + let mut c = if bg { app.session.tools.background } else { app.session.tools.foreground }; + let mut rgb = srgb_bytes(c).map(f32::from); + let dynamic = app.session.prefs().interface.dynamic_color_sliders; + let mut changed = false; + let [r0, g0, b0] = rgb.map(|v| v.round() as u8); + let r_stops = [Color32::from_rgb(0, g0, b0), Color32::from_rgb(255, g0, b0)]; + changed |= widgets::slider_row(ui, tl!("Red"), &mut rgb[0], 0.0..=255.0, "", dynamic.then_some(&r_stops)).changed(); + let g_stops = [Color32::from_rgb(r0, 0, b0), Color32::from_rgb(r0, 255, b0)]; + changed |= widgets::slider_row(ui, tl!("Green"), &mut rgb[1], 0.0..=255.0, "", dynamic.then_some(&g_stops)).changed(); + let b_stops = [Color32::from_rgb(r0, g0, 0), Color32::from_rgb(r0, g0, 255)]; + changed |= widgets::slider_row(ui, tl!("Blue"), &mut rgb[2], 0.0..=255.0, "", dynamic.then_some(&b_stops)).changed(); + if changed { + let q = |v: f32| (v.clamp(0.0, 255.0).round() / 255.0).clamp(0.0, 1.0); + c = [q(rgb[0]), q(rgb[1]), q(rgb[2]), 1.0]; + if bg { + app.session.tools.background = c; + } else { + app.session.tools.foreground = c; + crate::type_tool::foreground_changed(app); + } } } @@ -2454,7 +2663,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut actions: Vec<(String, Value)> = Vec::new(); let t = Tokens::get(ui.ctx()); - if t.pro { + app.reorder_preview_hit = false; + if !ui.input(|i| i.pointer.primary_down()) || !app.ui.layers_panel_options.preview_on_canvas_when_reordering_layers { + app.reorder_preview = None; + } + if t.pro && app.ui.layers_panel_options.show_filters { crate::layer_filter_ui::bar(app, ui); ui.add_space(4.0); } @@ -2621,7 +2834,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !l.effects.items.is_empty() && fx_collapsed.iter().all(|id| *id != l.id) { effect_rows(app, ui, l, depth, &mut actions); } - crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions); + if !fx_collapsed.contains(&l.id) { + crate::smart_ui::filter_rows(app, ui, &doc, l, depth, &mut actions); + } } // #1721: the list takes drops everywhere, not only on a row (Photoshop): the space // between two rows lands above the lower one, the empty space below the bottom-most @@ -2633,6 +2848,10 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some(&(last, last_rect)) = drawn.last() { let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom())); layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions); + // A click on that empty space deselects every layer, as in Photoshop (#8100). + if below.height() > 0.0 && ui.interact(below, ui.id().with("layers-empty-space"), Sense::click()).clicked() && !selection.is_empty() { + actions.push(("select.deselectLayers".into(), json!({}))); + } } // Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The // Background isn't renamed in place (double-clicking it converts it), so it ends there. @@ -2654,7 +2873,14 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { { actions.push(done); } + // Likewise a selected smart filter whose row is gone. + crate::smart_ui::forget_hidden(ui.ctx(), doc.id); }); + // Leaving the list or hovering an invalid target immediately cancels the temporary + // image. The release still uses the actual command, not the preview snapshot. + if !app.reorder_preview_hit { + app.reorder_preview = None; + } // A layer being dragged can also be dropped on the footer's Delete, New Layer and Group // buttons (#736); read the drag before it ends. let footer_drag = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag"))).map(|id| { @@ -2925,6 +3151,22 @@ struct RowSel { multi: bool, } +/// The badge beside the pointer over a thumbnail ⌘-click loads with these modifiers: none for a +/// new selection, + for ⇧ (add), − for ⌥ (subtract), × for ⇧⌥ (intersect). +pub(crate) fn thumb_load_badge(m: egui::Modifiers) -> Option { + crate::tool_feedback::Badge::from_mode(crate::channels_panel::load_operation(m)) +} + +/// The cursor over a thumbnail ⌘-click loads as a selection: a pointing hand, with the selection +/// mode's badge (#7824). +fn thumb_load_cursor(ctx: &egui::Context, p: Pos2, m: egui::Modifiers) { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + if let Some(badge) = thumb_load_badge(m) { + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("thumb-load-badge"))); + crate::tool_feedback::draw_badge(&painter, p, badge, false); + } +} + /// `layer.select` mode for a click with these modifiers (Photoshop: ⌘-click toggles, ⇧-click /// selects a range, a plain click selects one layer). fn select_mode(m: egui::Modifiers) -> &'static str { @@ -2985,6 +3227,8 @@ fn paint_clipping_indicator(painter: &egui::Painter, at: Pos2, color: Color32) { painter.add(egui::Shape::line(vec![at + vec2(2.0, 0.0), tip, at + vec2(8.0, 0.0)], stroke)); } +/// One row of the Layers panel for `l`, `depth` levels inside groups or artboards: it paints the +/// row, queues what a click or drag on it asks for in `actions`, and returns the row's rect. #[allow(clippy::too_many_arguments)] fn layer_row( app: &mut PhotocraftApp, @@ -2998,9 +3242,13 @@ fn layer_row( ) -> Rect { let selected = row.selected; let t = Tokens::get(ctx); - // Photoshop's default (medium) thumbnails: 32 pt rows. - let row_h = if t.pro { 32.0 } else { 46.0 }; + // The Layers Panel Options thumbnail size controls rows and both layer/mask thumbnails. + let (base, row_h) = crate::layers_panel_ui::thumbnail_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag()); + // With thumbnails off no mask thumbnail is drawn, so none may keep catching drags from older frames. + if base == 0.0 { + crate::mask_thumbs_ui::forget(ctx, l.id.0); + } // A drag from a mask's thumbnail drags that mask, not the layer (#3113, #3119). let mask_drag = starts_mask_drag(ctx, l, &resp); if let Some(kind) = mask_drag { @@ -3079,24 +3327,30 @@ fn layer_row( + if l.is_group() { crate::layer_tree_ui::TRIANGLE_W } else { 0.0 } + if l.clipped { 12.0 } else { 0.0 } + crate::layer_row_ui::reserved_width(l); - let ts = crate::mask_thumbs_ui::thumb_size(l, if t.pro { 24.0 } else { 34.0 }, rect.width() - others); - let fixed = others + ts + 6.0 + crate::mask_thumbs_ui::width(l, ts); - x += 28.0 + crate::layer_row_ui::indent(depth, rect.width(), fixed); + let ts = if base == 0.0 { 0.0 } else { crate::mask_thumbs_ui::thumb_size(l, base, rect.width() - others) }; + let fixed = others + if ts == 0.0 { 0.0 } else { ts + 6.0 + crate::mask_thumbs_ui::width(l, ts) }; + // A layer inside a group (or an artboard) indents one step more than its depth, past the + // triangle's column, so it sits right of its parent: with only its depth its thumbnail lined + // up under the group's, hiding the hierarchy. Top-level rows don't move. + let steps = if !l.is_group() && depth > 0 { depth + 1 } else { depth }; + x += 28.0 + crate::layer_row_ui::indent(steps, rect.width(), fixed); let toggled = crate::layer_tree_ui::disclosure(ui, rect, &mut x, l, actions); if l.clipped { paint_clipping_indicator(&painter, pos2(x, rect.center().y), t.text_faint); x += 12.0; } let thumb = Rect::from_min_size(pos2(x, rect.center().y - ts / 2.0), vec2(ts, ts)); - draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary); - x += ts + 6.0; - // Link chains and pixel / vector mask thumbnails (#153). - let masks = crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions); + if ts > 0.0 { + draw_layer_thumb(app, ctx, ui, doc, l, thumb, row.primary); + x += ts + 6.0; + } + // With thumbnails disabled the labels and disclosure controls remain interactive. + let masks = if ts > 0.0 { crate::mask_thumbs_ui::paint(app, ctx, ui, &painter, doc, l, &mut x, rect.center().y, ts, actions) } else { Default::default() }; let mask_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Pixel); let vector_rect = masks.thumb(crate::mask_thumbs_ui::MaskKind::Vector); // Photoshop frames the targeted thumbnail (pixels, mask or vector mask) of the active layer // with corner brackets. - if row.primary { + if row.primary && ts > 0.0 { let target = if app.ui.vector_mask_target && vector_rect.is_some() { vector_rect } else if app.ui.mask_target { @@ -3130,7 +3384,7 @@ fn layer_row( // Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright. let italic = !t.pro && doc.is_background(l.id); // Photoshop rows show only the name; the kind sub-label is a Studio-theme addition. - let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_)); + let is_pixel = t.pro || ts == 0.0 || matches!(l.content, LayerContent::Raster(_)); let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| { let text_pos = pos2(x, rect.center().y - galley.size().y / 2.0 - if is_pixel { 0.0 } else { 7.0 }); let r = Rect::from_min_size(text_pos, galley.size()); @@ -3145,18 +3399,31 @@ fn layer_row( }; crate::layer_row_ui::label(&painter, x, rect.center().y + 8.0, name_right, &sub, egui::FontId::proportional(11.0), t.text_faint); } - crate::layer_row_ui::record(ctx, crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, name: name_rect, indicators }); + crate::layer_row_ui::record( + ctx, + crate::layer_row_ui::RowRects { layer: l.id.0, row: rect, thumbnail: (ts > 0.0).then_some(thumb), name: name_rect, indicators }, + ); // ⌘-click a layer, mask or vector-mask thumbnail loads its transparency / mask / path as a // selection (⇧ add, ⌥ subtract, ⇧⌥ intersect) instead of changing the layer selection. - let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { - let p = pos.filter(|_| m.command)?; + let load_at = |m: egui::Modifiers, p: Pos2| { + if !m.command { + return None; + } if let Some(kind) = masks.hit(p) { return Some(crate::mask_thumbs_ui::load_params(l, kind, m)); } // The Background has no transparency to load: Photoshop treats the click as a plain one. - (!doc.is_background(l.id) && thumb.expand(2.0).contains(p)) + (ts > 0.0 && !doc.is_background(l.id) && thumb.expand(2.0).contains(p)) .then(|| json!({"channel": "transparency", "layer": l.id.0, "operation": crate::channels_panel::load_operation(m)})) - }); + }; + let thumb_load = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| load_at(m, pos?)); + // Over a thumbnail that ⌘ would load, the cursor says what the click does, as in Photoshop (#7824). + if let Some(p) = resp.hover_pos() { + let m = ui.input(|i| i.modifiers); + if load_at(m, p).is_some() { + thumb_load_cursor(ctx, p, m); + } + } // ⇧-click a mask thumbnail: disable / enable that mask; ⌥-click a layer mask: view it. let mask_toggle = resp.clicked().then(|| (ui.input(|i| i.modifiers), resp.interact_pointer_pos())).and_then(|(m, pos)| { let kind = masks.hit(pos?)?; @@ -3169,7 +3436,7 @@ fn layer_row( } else if resp.clicked() && !eye_resp.clicked() && !toggled && !fx_toggled && !masks.clicked { let m = ui.input(|i| i.modifiers); let pos = resp.interact_pointer_pos(); - let on_thumb = pos.is_some_and(|p| thumb.expand(2.0).contains(p)); + let on_thumb = ts > 0.0 && pos.is_some_and(|p| thumb.expand(2.0).contains(p)); // ⌘-click on the Background thumbnail stands in for the plain click (there is no // transparency to load): it selects the Background alone, not toggled into the set. let mode = if m.command && on_thumb && doc.is_background(l.id) { "replace" } else { select_mode(m) }; @@ -3234,9 +3501,9 @@ fn layer_row( } } else if pos.and_then(|p| masks.hit(p)).is_none() { let id = match &l.content { - LayerContent::Adjustment(_) | LayerContent::Fill(_) | LayerContent::Shape(_) if on(thumb) => "layer.layerContentOptions", - LayerContent::Smart(_) if on(thumb) => "layer.smartObjects.editContents", - LayerContent::Text(_) if on(thumb) => "type.editText", + LayerContent::Adjustment(_) | LayerContent::Fill(_) | LayerContent::Shape(_) if ts > 0.0 && on(thumb) => "layer.layerContentOptions", + LayerContent::Smart(_) if ts > 0.0 && on(thumb) => "layer.smartObjects.editContents", + LayerContent::Text(_) if ts > 0.0 && on(thumb) => "type.editText", _ => "layer.layerStyle.blendingOptions", }; if crate::menus::is_enabled(app, id) { @@ -3341,7 +3608,12 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); + let source = photocraft_engine::history_source_cmds::source(st); let selected_snap = selected_snapshot(ui.ctx(), st); + // The newest past state is a new document on every step, Undo and Redo, also at the state + // limit, where the counts and a repeated name stay the same. + let newest = st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).map(|d| std::sync::Arc::as_ptr(&d) as usize); + let spot = (doc.id, history_spot(st), newest); let ctx = ui.ctx().clone(); // A row dragged onto the trash (#5324); a drag that never saw its release must not outlive it. if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { @@ -3364,14 +3636,26 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let mut snap_cmd = None; let mut target: Option = None; + let mut set_source: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; let max_h = (ui.available_height() - footer).max(40.0); + // Follow the current state when the history moves (a new step, Undo, a state row) or another + // document comes up (`spot`), scrolling only if its row is hidden (#5909). Other changes (a + // layer click, a channel eye) and a selected snapshot leave the list where the user put it. + let follow_id = egui::Id::new("history-follow"); + let moved = ui.ctx().data_mut(|d| { + let moved = d.get_temp::<(photocraft_doc::DocId, HistorySpot, Option)>(follow_id).as_ref() != Some(&spot); + d.insert_temp(follow_id, spot); + moved + }); + let follow = moved && selected_snap.is_none(); egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { // Snapshots head the list and scroll with the states, so many of them never push the // footer out of a short dock. snap_cmd = snapshot_rows(app, ui, selected_snap); + let mut current_rect = None; for (i, (e, is_redo)) in all.enumerate() { // The current state shows selected unless a snapshot is (Photoshop highlights one row). let selected = i == current && selected_snap.is_none(); @@ -3382,6 +3666,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if resp.drag_started() { ui.ctx().data_mut(|d| d.insert_temp(history_drag_key(), HistoryDrag::State(i))); } + if i == current { + current_rect = Some(rect); + } if selected { ui.painter().rect_filled(rect, if t.pro { 0.0 } else { t.radius_sm }, if t.pro { t.row_selected } else { t.accent_soft }); } else if resp.hovered() { @@ -3406,6 +3693,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if is_redo { t.text_faint } else { t.icon }, ); ui.painter().text(pos2(rect.left() + 58.0, rect.center().y), Align2::LEFT_CENTER, &e, egui::FontId::proportional(12.0), color); + if history_source_column(ui, rect, egui::Id::new(("history-source", i)), source == Some(HistorySource::State(i))) { + set_source = Some(i); + } if resp.clicked() && i != current { target = Some(i); } else if resp.clicked() && selected_snap.is_some() { @@ -3413,7 +3703,17 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.remove::(selected_snapshot_key(doc.id))); } } + if follow && let Some(rect) = current_rect { + // No alignment: a row already in view stays put. No animation, as the Layers panel reveal. + ui.scroll_to_rect_animation(rect, None, egui::style::ScrollAnimation::none()); + } }); + if let Some(i) = set_source + && let Err(e) = app.run("history.setSource", json!({"state": i})) + { + app.ui.status = e; + app.ui.status_error = true; + } if let Some(drag) = drag && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) { @@ -3471,7 +3771,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The snapshot stays selected while the document is the state the click made. Ok(_) => { if let (Some(id), Some(st)) = (picked, app.session.active()) { - ctx.data_mut(|d| d.insert_temp::(selected_snapshot_key(st.doc.id), (id, history_spot(st)))); + select_snapshot(&ctx, st, id); } } } @@ -3486,6 +3786,21 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The History panel's source column at the left of a state or snapshot row: the History Brush +/// icon on the source; a click makes the row the source (`history.setSource`). It sits above the +/// row, so the click doesn't also step to the state or snapshot. +fn history_source_column(ui: &mut egui::Ui, row: Rect, salt: egui::Id, is_source: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let cell = Rect::from_min_size(row.min, vec2(24.0, row.height())); + let resp = ui.interact(cell, ui.id().with(salt), Sense::click()).on_hover_text(tl!("Sets the source for the history brush")); + if is_source { + icons::paint(ui, cell.shrink(4.0), icons::tool_icon(Tool::HistoryBrush), 12.0, t.icon); + } else if resp.hovered() { + icons::paint(ui, cell.shrink(4.0), icons::tool_icon(Tool::HistoryBrush), 12.0, t.text_faint); + } + resp.clicked() +} + /// A History panel row being dragged, for a drop on the trash (#5324). #[derive(Clone, Copy, Debug)] enum HistoryDrag { @@ -3510,10 +3825,15 @@ fn selected_snapshot_key(doc: photocraft_doc::DocId) -> egui::Id { egui::Id::new(("history-snapshot-selected", doc)) } +/// Select snapshot `id` at the history's current state, as a click on its row does. +pub(crate) fn select_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState, id: u64) { + ctx.data_mut(|d| d.insert_temp::(selected_snapshot_key(st.doc.id), (id, history_spot(st)))); +} + /// The selected snapshot: the one last clicked, while it still exists and the history is still at /// the state that click made. Any other step (an edit, Undo, a state row) deselects it, as in /// Photoshop. -fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option { +pub(crate) fn selected_snapshot(ctx: &egui::Context, st: &photocraft_engine::DocState) -> Option { let key = selected_snapshot_key(st.doc.id); let (id, spot) = ctx.data(|d| d.get_temp::(key))?; let live = spot == history_spot(st) && st.snapshots.iter().any(|s| s.id == id); @@ -3532,6 +3852,7 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option = snaps.iter().map(|s| s.id).collect(); // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. let rename_id = egui::Id::new(("history-snapshot-rename", doc_id)); @@ -3579,7 +3900,9 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, selected: Option = visible.iter().map(|(i, _)| *i).collect(); + let tabs: Vec<&str> = visible.iter().map(|(_, name)| *name).collect(); + let mut sel = indices.iter().position(|i| *i == app.ui.dock_tabs.properties).unwrap_or(0); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); + let hide = Rect::from_min_max(pos2(row.right() - 22.0, row.top() + 1.0), pos2(row.right(), row.bottom() - 1.0)); + if icons::button(&mut ui.new_child(egui::UiBuilder::new().max_rect(hide)), "minus", 22.0, false, tl!("Hide Properties")).clicked() { + app.ui.panels.properties = false; + } + // The title bar drags the card; registered before the tabs so they keep their clicks. + let bar = Rect::from_min_max(row.min, pos2((hide.left() - 6.0).max(row.left()), row.bottom())); let grip = ui.interact(bar, card_id.with("title"), Sense::drag()); if grip.hovered() || grip.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } drag = grip.drag_delta(); + let strip = crate::tab_strip::pill_tabs(ui, card_id.with("tabs"), bar, &tabs, &mut sel, false); + if let Some(&picked) = indices.get(sel) { + app.ui.dock_tabs.properties = picked; + } + match strip.context { + Some(crate::tab_strip::TabContextAction::Close(i)) => { + if let Some(&tab) = indices.get(i) { + crate::dock::close_tab(app, group, tab, false); + } + } + Some(crate::tab_strip::TabContextAction::CloseGroup) => app.ui.panels.properties = false, + None => {} + } ui.add_space(6.0); widgets::hairline(ui); ui.add_space(8.0); - properties_body(app, ui); + match indices.get(sel) { + Some(0) | None => properties_body(app, ui), + Some(_) => adjustments_grid(app, ui), + } }); }); if drag != egui::Vec2::ZERO && canvas.is_positive() { @@ -3694,7 +4038,7 @@ fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let t = Tokens::get(ui.ctx()); if let Some(s) = layer.surface() { - let b = app.cached_bounds(layer.id.0, s); + let b = app.layer_frame_bounds(layer, s); egui::Grid::new("props-grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for (k, v) in [("X", b.x0.to_string()), ("Y", b.y0.to_string()), ("W", b.width().to_string()), ("H", b.height().to_string())] { ui.label(RichText::new(k).color(t.text_dim)); @@ -3782,6 +4126,7 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if matches!(layer.content, LayerContent::Shape(_)) { crate::vector_ui::shape_properties(app, ui, id); + crate::mask_props_ui::shape_properties(app, ui, layer); } } } @@ -3871,7 +4216,9 @@ pub(crate) fn offer_color_field_fill(ui: &egui::Ui, pro: bool, group: crate::doc /// which folds onto a second line in a narrow dock. 120 pt when nothing sets a height. fn color_field_height(avail: Option, width: f32) -> f32 { let reserve = 32.0 + if width < 280.0 { 26.0 } else { 0.0 }; - avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(120.0, 2000.0)) + // When docked, shrink the field so the hex/RGB readout stays in view (#6885). A 120 pt floor + // here pushed the readout below the scroll area in a compact Color group. + avail.filter(|a| a.is_finite()).map_or(120.0, |a| (a - reserve).clamp(0.0, 2000.0)) } /// The colour the SV field draws for `hsva`, with what the field showed last (`hue`, `saturation`) @@ -3974,6 +4321,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else { app.session.tools.foreground = hsva_srgb(hsva); crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui)); } ui.data_mut(|d| d.insert_temp(key, (hsva.h, hsva.s))); } @@ -3987,6 +4335,7 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else { app.session.tools.foreground = c; crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, crate::shape_fill_ui::gesture(ui)); } // Keep the hue for greys, so the field marker doesn't jump to red; the saturation with // it, so a later black lands on the corner it was last dragged to (#3228). @@ -4009,12 +4358,13 @@ fn srgb_bytes(c: [f32; 4]) -> [u8; 3] { /// so a 50% grey has Brightness 50%. egui's `Hsva` conversions work in linear light, which put /// that grey at 22% and drew the field's middle as 188 (#3323); `Hsva` only holds the numbers. fn srgb_hsva(c: [f32; 4]) -> egui::ecolor::Hsva { - let [h, s, v] = crate::color_picker_ui::rgb_to_hsv([c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0))); + let [h, s, v] = photocraft_color::convert::rgb_to_hsb([c[0], c[1], c[2]].map(|v| v.clamp(0.0, 1.0))); egui::ecolor::Hsva::new(h / 360.0, s, v, 1.0) } +/// The inverse of srgb_hsva: the Color panel's HSB back to sRGB-encoded values. fn hsva_srgb(h: egui::ecolor::Hsva) -> [f32; 4] { - let [r, g, b] = crate::color_picker_ui::hsv_to_rgb(h.h * 360.0, h.s.clamp(0.0, 1.0), h.v.clamp(0.0, 1.0)); + let [r, g, b] = photocraft_color::convert::hsb_to_rgb([h.h * 360.0, h.s.clamp(0.0, 1.0), h.v.clamp(0.0, 1.0)]); [r, g, b, 1.0] } @@ -4224,7 +4574,7 @@ fn background_drop_refused(doc: &photocraft_doc::Document, payload: &Value, targ /// release the layers stay put and copies land there instead (Photoshop). /// Multi-layer moves are atomic (one undo step), using the engine's stable document order. fn layer_drag_and_drop( - app: &PhotocraftApp, + app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, l: &Layer, @@ -4268,12 +4618,15 @@ fn layer_drag_and_drop( } else { painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); } + let mut payload = layer_drop_payload(dragged, l.id, position, &selected); + if let Some(o) = payload.as_object_mut() { + o.insert("copy".into(), json!(true)); + } if released { - let mut payload = layer_drop_payload(dragged, l.id, position, &selected); - if let Some(o) = payload.as_object_mut() { - o.insert("copy".into(), json!(true)); - } + app.reorder_preview = None; actions.push(("layer.moveTo".into(), payload)); + } else if ctx.input(|i| i.pointer.primary_down()) { + crate::layers_panel_ui::preview_reorder(app, &payload); } } return; @@ -4307,10 +4660,14 @@ fn layer_drag_and_drop( painter.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(2.0, t.accent)); } } + if copy && let Some(o) = payload.as_object_mut() { + o.insert("copy".into(), json!(true)); + } + if !released && ctx.input(|i| i.pointer.primary_down()) { + crate::layers_panel_ui::preview_reorder(app, &payload); + } if released { - if copy && let Some(o) = payload.as_object_mut() { - o.insert("copy".into(), json!(true)); - } + app.reorder_preview = None; actions.push(("layer.moveTo".into(), payload)); } } @@ -4320,7 +4677,7 @@ fn layer_drag_and_drop( /// with ⌥ held on release (Photoshop), a move without. A multi-selection dropped on the row it /// was grabbed from is left alone: the engine can only copy it beside each of its own layers. fn layer_drop_zone( - app: &PhotocraftApp, + app: &mut PhotocraftApp, ctx: &egui::Context, ui: &egui::Ui, zone: Rect, @@ -4340,14 +4697,17 @@ fn layer_drop_zone( let t = Tokens::get(ctx); let line = if position == "above" { [target.1.left_top(), target.1.right_top()] } else { [target.1.left_bottom(), target.1.right_bottom()] }; ui.painter().line_segment(line, Stroke::new(2.0, t.accent)); + let mut payload = layer_drop_payload(dragged, target.0, position, &selected); + if ctx.input(|i| i.modifiers.alt) + && let Some(o) = payload.as_object_mut() + { + o.insert("copy".into(), json!(true)); + } if ctx.input(|i| i.pointer.any_released()) { - let mut payload = layer_drop_payload(dragged, target.0, position, &selected); - if ctx.input(|i| i.modifiers.alt) - && let Some(o) = payload.as_object_mut() - { - o.insert("copy".into(), json!(true)); - } + app.reorder_preview = None; actions.push(("layer.moveTo".into(), payload)); + } else if ctx.input(|i| i.pointer.primary_down()) { + crate::layers_panel_ui::preview_reorder(app, &payload); } } @@ -4493,6 +4853,16 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi } } +/// The options bar's Select and Mask… button: greyed out, as the menu item is, until there is a +/// selection (or a layer mask) to refine (#7520). +fn select_and_mask_button(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let enabled = crate::menus::is_enabled(app, "select.selectAndMask"); + let clicked = ui.add_enabled_ui(enabled, |ui| widgets::secondary_button(ui, tl!("Select and Mask…"), 0.0).clicked()).inner; + if clicked { + let _ = crate::menus::invoke(app, ui.ctx(), "select.selectAndMask", json!({})); + } +} + /// New / add / subtract / intersect selection buttons (shared by selection tools). fn selection_mode_buttons(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; @@ -4550,7 +4920,7 @@ mod color_tests { assert_eq!(hsva_color32(egui::ecolor::Hsva::new(0.3, 0.0, 0.5, 1.0)), Color32::from_gray(128)); // The same numbers as the Color Picker dialog for a saturated colour. let c = [0.847, 0.271, 0.180]; - let (ours, dialog) = (srgb_hsva([c[0], c[1], c[2], 1.0]), crate::color_picker_ui::rgb_to_hsv(c)); + let (ours, dialog) = (srgb_hsva([c[0], c[1], c[2], 1.0]), photocraft_color::convert::rgb_to_hsb(c)); assert!((ours.h * 360.0 - dialog[0]).abs() < 0.5 && (ours.s - dialog[1]).abs() < 1e-3 && (ours.v - dialog[2]).abs() < 1e-3, "{ours:?} vs {dialog:?}"); } @@ -4577,6 +4947,26 @@ mod color_tests { } } + #[test] + fn rgb_slider_mode_shows_red_green_blue_rows() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.color_panel.slider_mode = ColorPanelSliderMode::Rgb; + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 360.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + color_picker(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(2); + for label in ["Red", "Green", "Blue"] { + h.get_by_label(label); + } + } + /// The Color panel's hex readout is editable, with or without the leading `#`. #[test] fn color_panel_hex_field_takes_a_typed_hex() { @@ -4614,9 +5004,13 @@ mod color_tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; app.session.tools.background = [0.0, 0.0, 1.0, 1.0]; - let mut h = egui_kittest::Harness::builder() - .with_size(vec2(80.0, 120.0)) - .build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, two_columns), app); + let mut h = egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let w = ui.available_width(); + color_chips(app, ui, two_columns, w) + }, + app, + ); PhotocraftApp::setup_context(&h.ctx, kind); h.run_steps(2); h.get_by_label("Swap colours (X)").click(); @@ -4659,6 +5053,7 @@ mod color_tests { let anchor = if selected { 6 } else { 11 }; app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 11, anchor, session: "s".into(), @@ -4668,8 +5063,13 @@ mod color_tests { resize: None, preedit: None, }); - let mut h = - egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state(|ui, app: &mut PhotocraftApp| color_chips(app, ui, false), app); + let mut h = egui_kittest::Harness::builder().with_size(vec2(80.0, 120.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let w = ui.available_width(); + color_chips(app, ui, false, w) + }, + app, + ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); h.run_steps(2); h.get_by_label("Swap colours (X)").click(); @@ -4793,17 +5193,40 @@ mod color_tests { assert_eq!(srgb_bytes(h.state().session.tools.foreground), [0, 255, 0]); } - /// The Color field fills the dock group, never shrinks below 120 pt, and ignores a bad height. + /// The Color field fills the dock group, shrinks to leave room for the readout in a short group, and ignores a bad height. #[test] fn color_field_height_fills_the_group() { assert_eq!(color_field_height(None, 300.0), 120.0); assert_eq!(color_field_height(Some(500.0), 300.0), 468.0); assert_eq!(color_field_height(Some(500.0), 200.0), 442.0); - assert_eq!(color_field_height(Some(60.0), 300.0), 120.0); + assert_eq!(color_field_height(Some(60.0), 300.0), 28.0); assert_eq!(color_field_height(Some(f32::NAN), 300.0), 120.0); assert_eq!(color_field_height(Some(1.0e9), 300.0), 2000.0); } + /// A squeezed Color group still shows the hex/RGB readout, not only the spectrum field (#6885). + #[test] + fn docked_color_field_keeps_rgb_readout_at_compact_height() { + use egui_kittest::kittest::Queryable; + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // Body height after tab strip and margins in a Color group at `compact_height` (130 pt). + let inner = 86.0; + let mut h = egui_kittest::Harness::builder().with_size(vec2(240.0, inner)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.set_height(inner); + ui.data_mut(|d| d.insert_temp(color_field_fill_id(), inner)); + color_field(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(2); + assert!(h.query_by_label("R").is_some(), "RGB readout stays visible when the group is short"); + assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "hex field stays visible"); + } + fn field_harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { // 60 fps steps, so two clicks a frame apart are a double-click. let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).with_step_dt(1.0 / 60.0).build_ui_state( @@ -4864,6 +5287,33 @@ mod color_tests { } } + /// #5568: with a shape layer selected, the Color panel recolours its fill (keeping the fill's + /// opacity): a click is one undo step and a whole drag across the field is one more. + #[test] + fn color_panel_recolours_the_selected_shape_fill() { + use photocraft_doc::{Fill, LayerContent, LayerId}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": [0.2, 0.4, 0.6, 0.5]})).unwrap()["layer"].as_u64().unwrap()); + let mut h = field_harness(app); + let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(h.state()); + let min = h.ctx.input(|i| i.viewport_rect()).min; + // The field's top-left: no saturation, full brightness. + click(&mut h, min + vec2(60.0, 10.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 1); + drag(&mut h, min + vec2(120.0, 40.0), min + vec2(250.0, 120.0)); + let fg = h.state().session.tools.foreground; + assert_eq!(fill(h.state()), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 0.5)))); + assert_eq!(steps(h.state()), before + 2, "the whole drag is one undo step"); + } + /// Press at `from`, move to `to` in six steps and release, as egui sees a drag. fn drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); @@ -5053,6 +5503,51 @@ mod history_transform_tests { assert_eq!(st.history.entries().len(), steps - 1, "trash deletes the current state"); assert!(!st.history.can_redo(), "and it can't be redone"); } + + /// The source column at the left of each state (#2966): a click makes that state the History + /// Brush's source through `history.setSource`, without stepping to it. + #[test] + fn history_source_column_sets_the_history_brush_source() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(2); + // The column cells are the only 24 pt squares, one per state, top to bottom. + let mut cells: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 24.0)).map(|w| w.rect).collect()); + cells.sort_by(|a, b| a.center().y.total_cmp(&b.center().y)); + cells.dedup(); + assert_eq!(cells.len(), 3, "Open, Fill, Fill: {cells:?}"); + assert_eq!(h.state().session.active().unwrap().history.brush_source(), Some(0), "the default source"); + click(&mut h, cells[1].center()); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.brush_source(), Some(1), "the clicked state is the source"); + assert_eq!((st.history.entries().len(), st.history.can_redo()), (3, false), "and the document did not step back"); + } + + /// Snapshot rows have the source column too: a click makes the snapshot the source without + /// returning to it. + #[test] + fn history_source_column_on_a_snapshot_row() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(2); + // The snapshot row's cell is the only 24×38 one (the opening row has no column). + let cells: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(24.0, 38.0)).map(|w| w.rect).collect()); + let cell = *cells.first().expect("a snapshot source cell"); + click(&mut h, cell.center()); + let st = h.state().session.active().unwrap(); + assert_eq!(photocraft_engine::history_source_cmds::source(st), Some(HistorySource::Snapshot(1))); + assert_eq!(st.history.entries().len(), 2, "the document did not return to the snapshot"); + } } #[cfg(test)] @@ -5285,6 +5780,10 @@ mod layer_pct_slider_tests; #[path = "fx_drag_tests.rs"] mod fx_drag_tests; +#[cfg(test)] +#[path = "unsupported_marker_tests.rs"] +mod unsupported_marker_tests; + #[cfg(test)] mod lock_tests { use super::*; @@ -5352,6 +5851,7 @@ mod swatch_type_tests { app.ui.tool = Tool::Type; app.ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 0, anchor: 5, session: "s".into(), @@ -5370,6 +5870,82 @@ mod swatch_type_tests { } } +#[cfg(test)] +mod tool_flyout_drag_tests { + use super::*; + + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) { + let mut out = ctx.run_ui( + egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); + } + + fn primary(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + #[test] + fn flyout_drag_only_opens_after_a_primary_press_leaves_its_button() { + let button = Rect::from_min_size(pos2(20.0, 20.0), vec2(36.0, 36.0)); + let inside = button.center(); + let outside = pos2(120.0, 34.0); + assert!(!tool_flyout_drag_open(button, Some(inside), Some(inside), true)); + assert!(!tool_flyout_drag_open(button, Some(inside), Some(outside), false)); + assert!(!tool_flyout_drag_open(button, Some(outside), Some(outside), true)); + assert!(!tool_flyout_drag_open(button, None, Some(outside), true)); + assert!(tool_flyout_drag_open(button, Some(inside), Some(outside), true)); + let row = Rect::from_min_size(pos2(90.0, 20.0), vec2(160.0, 26.0)); + assert!(tool_flyout_release(row, Some(row.center()), true, true)); + assert!(!tool_flyout_release(row, Some(row.center()), false, true)); + assert!(!tool_flyout_release(row, Some(row.center()), true, false)); + assert!(!tool_flyout_release(row, Some(outside + vec2(0.0, 200.0)), true, true)); + assert!(!tool_flyout_release(row, None, true, true)); + } + + /// #2301: the press begins on the toolbar button, not on a flyout row. No long-press + /// delay is needed; releasing over the second row picks it and closes the flyout. + #[test] + fn flyout_press_drag_release_selects_the_row_without_long_press() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let slot_index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let origin = buttons[slot_index].center(); + let key = egui::Id::new(("tool-slot", slot_index)); + let flyout_id = egui::Id::new("tool-flyout"); + let initial = app.ui.tool; + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(origin), primary(origin, true)]); + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none()); + // Move right of the icon, still held. The flyout must appear before 350 ms. + frame(&mut app, &ctx, 1.08, vec![egui::Event::PointerMoved(origin + vec2(bx + 70.0, 0.0))]); + assert_eq!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).map(|(id, _)| id), Some(key)); + assert_eq!(app.ui.tool, initial, "drag-opening doesn't activate the starting tool"); + let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).expect("flyout area"); + let row = pos2(menu.left() + 65.0, menu.top() + 47.0); + frame(&mut app, &ctx, 1.12, vec![egui::Event::PointerMoved(row)]); + frame(&mut app, &ctx, 1.16, vec![primary(row, false)]); + assert_eq!(app.ui.tool, Tool::Pencil); + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none()); + frame(&mut app, &ctx, 1.2, vec![]); + assert_eq!(app.ui.tool, Tool::Pencil, "the original button cannot activate on release"); + } +} + #[cfg(test)] mod type_flyout_tests { use super::*; @@ -5987,6 +6563,24 @@ mod toolbar_tests { assert_eq!(h.state().ui.tool, Tool::Zoom); assert!(h.state().session.active().is_none()); } + + /// A press and release with no pointer move before it (first click after focus, a pen or + /// touch tap) picks the tool: the button doesn't need to have been hovered (#2603). + #[test] + fn a_click_without_a_prior_hover_picks_the_tool() { + for tool in [Tool::Brush, Tool::Type] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Move; + let mut h = toolbar_harness(app); + let p = tool_button(&h, tool); + assert!(h.ctx.input(|i| i.pointer.latest_pos()).is_none(), "the pointer has not entered the window"); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + assert_eq!(h.state().ui.tool, tool); + } + } } #[cfg(test)] @@ -6361,6 +6955,51 @@ mod compact_visual_regressions { } } + /// #7316: a window too short for the tools scrolls them, and the scroll bar widened the UI + /// the colour chips were centred in, so they (and Switch Colors with them) sat a bar's width + /// right of the tools. They stay where they are in a window tall enough not to scroll. + #[test] + fn color_chips_stay_centred_when_the_tools_scroll() { + let chips = |kind: crate::theme::ThemeKind, double: bool, height: f32, scale: f32| { + let ctx = egui::Context::default(); + egui_extras::install_image_loaders(&ctx); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, kind); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.tools.foreground = [1.0, 0.0, 1.0, 1.0]; + app.session.tools.background = [0.0, 1.0, 0.0, 1.0]; + app.ui.panels.toolbar_double = double; + let input = || input_at_scale(Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, height)), scale); + // The scroll bar shows from the frame after the one that measured the tools. + let mut output = ctx.run_ui(input(), |ui| toolbar(&mut app, ui)); + for _ in 0..2 { + output.textures_delta.clear(); + output = ctx.run_ui(input(), |ui| toolbar(&mut app, ui)); + } + output.textures_delta.clear(); + [Color32::from_rgb(255, 0, 255), Color32::GREEN].map(|color| { + output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) if rect.fill == color => Some(rect.rect.center()), + egui::Shape::Circle(circle) if circle.fill == color => Some(circle.center), + _ => None, + }) + .unwrap_or_else(|| panic!("{kind:?}: both color chips are painted")) + .x + }) + }; + for kind in crate::theme::ThemeKind::ALL { + for double in [false, true] { + for scale in [1.0, 2.0] { + let (short, tall) = (chips(kind, double, 420.0, scale), chips(kind, double, 1400.0, scale)); + assert_eq!(short, tall, "{kind:?}, two columns {double}, scale {scale}: the chips don't move when the tools scroll"); + } + } + } + } + #[test] fn compact_toolbar_keeps_both_color_chips_visible() { for kind in crate::theme::ThemeKind::ALL { @@ -6552,6 +7191,46 @@ mod compact_visual_regressions { assert_eq!(app.ui.tool_options.crop_ratio, "1.414:1", "showing the ratio leaves it alone"); } + #[test] + fn a_typed_crop_ratio_fits_from_the_frame_the_edit_started_with() { + // #7752: typing 16 over 4 gave 1:3, then 16:3 inside that 100 x 300 frame (100 x 18.75). + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| { + crate::theme::apply(&ctx, *k); + Tokens::get(&ctx).pro + }) else { + return; + }; + crate::theme::apply(&ctx, kind); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.tool_options.crop_ratio = "4:3".into(); + app.ui.crop_rect = Some([0.0, 0.0, 400.0, 300.0]); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0)); + let step = |app: &mut PhotocraftApp, events: Vec| { + let mut input = input_at_scale(screen, 1.0); + input.events = events; + ctx.run_ui(input, |ui| options_bar(app, ui)).textures_delta.clear(); + }; + for _ in 0..3 { + step(&mut app, vec![]); + } + // The ratio's W field is the bar's first number field; focusing it selects its text. + widgets::focus_first_field(&ctx, true); + step(&mut app, vec![]); + step(&mut app, vec![egui::Event::Text("1".into())]); + assert_eq!(app.ui.tool_options.crop_ratio, "1:3"); + step(&mut app, vec![egui::Event::Text("6".into())]); + step(&mut app, vec![]); + assert_eq!(app.ui.tool_options.crop_ratio, "16:3"); + let r = app.ui.crop_rect.unwrap(); + let want = [0.0, 112.5, 400.0, 187.5]; + assert!(r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "16:3 fitted once in 400 x 300: {r:?}"); + } + #[test] fn the_narrow_options_bar_preference_shortens_the_bar() { assert_eq!(options_bar_height(true, false), 36.0); @@ -6682,6 +7361,43 @@ mod properties_target_regressions { } } +#[cfg(test)] +mod navigator_animated_zoom_tests { + use super::*; + use egui_kittest::kittest::Queryable; + + #[test] + fn animated_zoom_off_navigator_buttons_jump_without_a_transition() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].fit_pending = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(430.0, 460.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + navigator(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + for (label, direction) in [("Zoom In", 1), ("Zoom Out", -1), ("Zoom Out", -1)] { + let before = &h.state().ui.views[0]; + let zoom = crate::zoom_levels::step(before.zoom, direction, before.doc_size); + h.get_by_label(label).click(); + h.run_steps(2); + let view = &h.state().ui.views[0]; + assert_eq!(view.zoom, zoom, "{label}"); + assert_eq!(view.display_camera, Some((view.zoom, view.center)), "{label}"); + assert!(!view.zoom_animating && view.zoom_anchor.is_none() && view.zoom_transition.is_none(), "{label}"); + assert!(!view.fit_pending, "{label}"); + } + } +} + #[cfg(test)] #[path = "history_panel_select_tests.rs"] mod history_panel_select_tests; diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 12b1670863..a5d4ee035a 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -248,7 +248,8 @@ fn clicking_shape_with_path_selection_selects_its_layer() { assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); } -/// Work paths remain visible (outline) regardless of the active tool. Before the fix, +/// A work path selected in the Paths panel remains visible (outline) regardless of the active tool +/// (one that is not selected is hidden, #6551). Before the fix, /// `draw_overlay` was gated on `vector_tool` so paths disappeared when switching to Brush, Move, /// etc. This test renders the canvas with a Brush tool active and a work path present, then /// confirms the path's outline pixels are painted in the accent colour, proving the path is drawn. @@ -259,6 +260,7 @@ fn path_overlay_drawn_for_non_vector_tools() { app.sync_views(); // Place a work path whose top edge runs exactly along y=10 in document space. app.run("path.set", json!({"name": "work", "path": rect_path(50.0, 10.0, 100.0, 80.0)})).unwrap(); + app.ui.selected_path = Some("work".into()); // Switch to a non-vector tool — before the fix this hid the path outline. app.ui.tool = Tool::Brush; diff --git a/crates/ui-egui/src/paths_scroll_tests.rs b/crates/ui-egui/src/paths_scroll_tests.rs new file mode 100644 index 0000000000..924e6e6290 --- /dev/null +++ b/crates/ui-egui/src/paths_scroll_tests.rs @@ -0,0 +1,69 @@ +//! Scrolling the Paths panel moves only its list (#6238). + +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn harness(paths: usize, size: egui::Vec2, ppp: f32) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(size).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for i in 0..paths { + let x = (i % 8) as i32 * 40; + app.run("select.rect", json!({"x": x, "y": 20, "width": 30, "height": 30})).unwrap(); + app.run("select.toWorkPath", json!({"tolerance": 2})).unwrap(); + app.run("path.rename", json!({"name": "work", "to": format!("Path {}", i + 1)})).unwrap(); + } + // Layers | Channels | Paths: the Paths tab. + app.ui.dock_tabs.layers = 2; + app + }); + h.run_steps(8); + h +} + +/// Scrolling the path list moves the rows and nothing else: the footer's buttons stay where they +/// are. The Paths panel lays out its own list and footer, but the dock also wrapped it in a +/// scroll area; at 125 % scaling and some window heights that outer area overflowed by one +/// physical pixel, took part of the wheel and moved the footer about a pixel down and back up. +#[test] +fn scrolling_the_paths_list_does_not_move_the_footer() { + // Heights that moved it at 125 % (two bands of window heights), and ones that never did. + for (ppp, height) in [(1.25, 520.0), (1.25, 900.0), (1.25, 960.0), (1.0, 900.0), (1.5, 900.0)] { + let mut h = harness(30, vec2(1440.0, height), ppp); + let footer = |h: &Harness<'_, PhotocraftApp>| h.query_by_label("Delete path").map(|r| r.rect().min.y); + let rest = footer(&h).unwrap_or_else(|| panic!("the footer shows at {ppp}x, {height} high")); + let first_row = h.query_by_label("Path 1").unwrap().rect(); + h.hover_at(first_row.center()); + h.run_steps(2); + for i in 0..40 { + // Down to the end of the list and past it, then back up. + let dir = if i < 30 { -1.0 } else { 1.0 }; + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, dir), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(1); + assert_eq!(footer(&h), Some(rest), "{ppp}x, {height} high, notch {i}: the footer stayed put"); + } + // The list itself did scroll (the first row is out of view or has moved). + let moved = h.query_by_label("Path 1").map(|r| r.rect().min.y) != Some(first_row.min.y); + assert!(moved || h.query_by_label("Path 1").is_none(), "{ppp}x, {height} high: the list scrolled"); + } +} + +/// Channels, Paths and Layers are siblings with the same own-scroll layout, so none is wrapped in +/// the dock's scroll area. +#[test] +fn the_layers_group_tabs_all_scroll_themselves() { + use crate::dock::Group; + for tab in 0..=2 { + assert!(Group::Layers.scrolls_itself(tab), "Layers group tab {tab}"); + } + assert!(!Group::Layers.scrolls_itself(3), "a tab that does not exist is not special"); +} diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 57be940bd6..e11bd4abf2 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -1,5 +1,5 @@ //! The Pen's modifiers while drawing, through the real canvas (#3306): ⌥/Alt breaks the handle -//! being pulled out and grabs the last anchor's outgoing handle alone, and dragging on the first +//! being pulled out and grabs either of the last anchor's handles alone, and dragging on the first //! anchor closes the path with that anchor's handles pulled out. use egui::{Key, Modifiers, PointerButton, Pos2, vec2}; @@ -122,6 +122,33 @@ fn alt_breaks_the_handle_being_dragged_and_grabs_the_last_outgoing_handle() { assert!(near([k.out_ctrl.x, k.out_ctrl.y], [140.0, 20.0])); } +/// #6077: both handles of the last anchor are drawn, so ⌥/Alt grabs either one, as in Photoshop. +/// Dragging the incoming handle turns it alone; the outgoing handle and the anchors stay put. +#[test] +fn alt_drag_turns_the_last_anchors_incoming_handle_alone() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + drag(&mut h, &[[100.0, 20.0], [120.0, 40.0]], None); + let k = h.state().ui.pen.clone().unwrap().knots[1]; + assert!(near(k[1], [80.0, 0.0]) && near(k[2], [120.0, 40.0]), "smooth anchor pulled out: {k:?}"); + + drag(&mut h, &[[80.0, 0.0], [60.0, 30.0]], Some(0)); + let pen = h.state().ui.pen.clone().unwrap(); + assert_eq!(pen.knots.len(), 2, "no new anchor on the handle"); + let k = pen.knots[1]; + assert!(near(k[0], [100.0, 20.0]), "the anchor stayed put: {k:?}"); + assert!(near(k[1], [60.0, 30.0]), "the incoming handle followed the pointer: {k:?}"); + assert!(near(k[2], [120.0, 40.0]), "the outgoing handle stayed put: {k:?}"); + + click(&mut h, [150.0, 120.0]); + h.key_press(Key::Enter); + h.run_steps(2); + let doc = &h.state().session.active().unwrap().doc; + let k = &doc.work_path.as_ref().unwrap().subpaths[0].knots[1]; + assert!(!k.smooth, "the broken handle is a cusp in the work path"); + assert!(near([k.in_ctrl.x, k.in_ctrl.y], [60.0, 30.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [120.0, 40.0])); +} + #[test] fn opening_a_new_document_finishes_the_previous_pen_path() { let mut h = harness(); diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs index eae88384fe..cee8e37bbd 100644 --- a/crates/ui-egui/src/pen_variants_tests.rs +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -169,3 +169,107 @@ fn new_pens_with_command_edit_paths_as_direct_selection() { assert_eq!(app.ui.tool, tool); } } + +/// #6593: a Pen press on an endpoint of an open path carries on drawing it, as in Photoshop, +/// from its last anchor or (reversed) its first; the commit extends that subpath in one step +/// instead of adding a second one. Elsewhere the press starts a new subpath as before. +#[test] +fn the_pen_carries_on_an_open_path_from_either_end() { + let mut app = app(Tool::Pen); + let anchors = |app: &PhotocraftApp| -> Vec> { + work_path(app).subpaths.iter().map(|s| s.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect()).collect() + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.past_len(); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app), [vec![[20.0, 20.0], [80.0, 20.0]]]); + // From the last anchor. + let before = steps(&app); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app), [vec![[20.0, 20.0], [80.0, 20.0], [80.0, 80.0]]], "the same subpath, extended"); + assert_eq!(steps(&app), before + 1, "one History step"); + // From the first anchor: the subpath runs the other way and carries on past it. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app), [vec![[80.0, 80.0], [80.0, 20.0], [20.0, 20.0], [20.0, 80.0]]]); + // Anywhere else: a new subpath next to it. + click(&mut app, 150.0, 150.0); + click(&mut app, 200.0, 150.0); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(anchors(&app).len(), 2, "a second subpath"); + // Closing on the far end of a resumed path closes that subpath. + click(&mut app, 200.0, 150.0); + click(&mut app, 175.0, 190.0); + click(&mut app, 150.0, 150.0); + assert!(app.ui.pen.is_none(), "closed and committed"); + let work = work_path(&app); + assert_eq!((work.subpaths.len(), work.subpaths[1].closed, work.subpaths[1].knots.len()), (2, true, 3)); +} + +/// #6593 review: carrying on a subpath keeps its path operation, and a draft whose subpath changed +/// meanwhile is added as a new subpath instead of overwriting another one. +#[test] +fn a_resumed_subpath_keeps_its_operation_and_a_stale_draft_adds_a_new_one() { + let mut app = app(Tool::Pen); + let open = |ax: f64, bx: f64, op: &str| json!({"closed": false, "op": op, "knots": [{"anchor": [ax, 20.0]}, {"anchor": [bx, 20.0]}]}); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(20.0, 80.0, "subtract")]}})).unwrap(); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!((work.subpaths.len(), work.subpaths[0].knots.len()), (1, 3)); + assert_eq!(work.subpaths[0].op, photocraft_doc::vector::PathOp::Subtract, "the operation is kept"); + // The work path is replaced while the draft is open: the draft becomes a new subpath. + click(&mut app, 20.0, 20.0); + click(&mut app, 20.0, 80.0); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 180.0, "combine")]}})).unwrap(); + crate::vector_ui::pen_commit(&mut app, false); + let work = work_path(&app); + assert_eq!(work.subpaths.len(), 2, "a new subpath, the replaced one untouched"); + assert_eq!(work.subpaths[0].knots.iter().map(|k| k.anchor.x).collect::>(), [120.0, 180.0]); + // The same with nothing selected in the Paths panel: still added to the Work Path it came from. + click(&mut app, 180.0, 20.0); + click(&mut app, 180.0, 90.0); + app.ui.selected_path = None; + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 160.0, "combine")]}})).unwrap(); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "added, not replacing the Work Path"); + // And with another path selected: the draft still goes back to the path it came from. + app.run("path.set", json!({"name": "Other", "path": {"subpaths": [open(10.0, 30.0, "combine")]}})).unwrap(); + click(&mut app, 160.0, 20.0); + click(&mut app, 160.0, 90.0); + app.ui.selected_path = Some("Other".into()); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [open(120.0, 150.0, "combine")]}})).unwrap(); + crate::vector_ui::pen_commit(&mut app, false); + assert_eq!(work_path(&app).subpaths.len(), 2, "joined the Work Path it came from"); + let st = app.session.active().unwrap(); + let other = st.doc.paths.iter().find(|p| p.name == "Other").unwrap(); + assert_eq!(other.path.subpaths.len(), 1, "the selected path is untouched"); +} + +/// #6593 review: undoing points in a resumed draft never removes the path's own anchors; past the +/// added ones it drops the draft and the path stays as it was. +#[test] +fn undo_in_a_resumed_draft_stops_at_the_paths_own_anchors() { + let mut app = app(Tool::Pen); + click(&mut app, 20.0, 20.0); + click(&mut app, 80.0, 20.0); + crate::vector_ui::pen_commit(&mut app, false); + let before = work_path(&app); + click(&mut app, 80.0, 20.0); + click(&mut app, 80.0, 80.0); + assert!(crate::vector_ui::pen_undo_last_point(&mut app), "takes back the added anchor"); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + // A ⌘/Ctrl selection on the draft's anchors. + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + assert!(crate::vector_ui::pen_undo_last_point(&mut app), "drops the draft"); + assert!(app.ui.pen.is_none()); + assert_eq!(app.ui.direct_selection.target, None, "the selection on the dropped draft goes with it"); + assert!(app.ui.direct_selection.anchors.is_empty()); + assert_eq!(work_path(&app), before, "the path is as it was"); +} diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index d5557c529e..aea5743215 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -101,6 +101,50 @@ fn shift_click_draws_a_pencil_line() { assert_eq!(app.session.active().unwrap().history.past_len(), 2); } +#[test] +fn shift_clicked_one_pixel_line_uses_the_clicked_pixels_not_subpixel_positions() { + let fractions = [("TL", (0.05, 0.05)), ("TR", (0.95, 0.05)), ("BL", (0.05, 0.95)), ("BR", (0.95, 0.95)), ("C", (0.5, 0.5))]; + let starts = [&fractions[0], &fractions[1], &fractions[2], &fractions[3]]; + + for (start_name, start_fraction) in starts { + for (end_name, end_fraction) in fractions { + let paint = |raw: bool| { + let mut app = app(); + app.run("tools.setBrush", json!({"brush": {"size": 1.0, "hardness": 1.0}})).unwrap(); + let at = |origin: f64, fraction: f64| origin + if raw { fraction } else { 0.5 }; + let start = (at(10.0, start_fraction.0), at(10.0, start_fraction.1)); + let end = (at(60.0, end_fraction.0), at(45.0, end_fraction.1)); + for (point, mods) in [(start, Modifiers::NONE), (end, Modifiers::SHIFT)] { + tool_event(&mut app, ToolEvent::Down { x: point.0, y: point.1, pressure: 1.0 }, mods); + tool_event(&mut app, ToolEvent::Up { x: point.0, y: point.1 }, mods); + } + (0..80).flat_map(|y| (0..120).map(move |x| (x, y))).filter(|&(x, y)| rgba(&app, x, y)[3] > 0.0).collect::>() + }; + + assert_eq!(paint(true), paint(false), "{start_name}->{end_name}"); + } + } +} + +#[test] +fn shift_clicked_pencil_does_not_split_a_click_near_a_pixel_boundary() { + let mut app = app(); + app.run("tools.setBrush", json!({"brush": {"size": 1.0, "hardness": 1.0}})).unwrap(); + let click = |app: &mut PhotocraftApp, x: f64, y: f64, modifiers| { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, modifiers); + tool_event(app, ToolEvent::Up { x, y }, modifiers); + }; + + click(&mut app, 30.5, 30.5, Modifiers::NONE); + // The pointer is just below the grid corner; numerical rounding must not make the Pencil + // snap to one pixel and the line walker rasterize the neighbouring one. + let just_below_corner = 30.0 - 5e-8; + click(&mut app, just_below_corner, just_below_corner, Modifiers::SHIFT); + + assert_eq!(rgba(&app, 29, 29)[3], 0.0, "the click should remain in the pixel chosen by the line walker"); + assert_eq!(rgba(&app, 30, 30)[3], 1.0); +} + #[test] fn ctrl_alt_drag_resizes_the_pencil_without_painting() { let mut app = app(); diff --git a/crates/ui-egui/src/perspective_crop_ui.rs b/crates/ui-egui/src/perspective_crop_ui.rs index c44b54dc4a..396fb87997 100644 --- a/crates/ui-egui/src/perspective_crop_ui.rs +++ b/crates/ui-egui/src/perspective_crop_ui.rs @@ -59,8 +59,9 @@ pub fn cancel(app: &mut PhotocraftApp) { app.pcrop.clicks.clear(); } -/// Another document became active: its pending quad is dropped. -fn cancel_stale(app: &mut PhotocraftApp) { +/// Another document became active: its pending quad is dropped (also from `sync_views`, so it is +/// never drawn on the other document). +pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { let doc = app.session.active().map(|st| st.doc.id); if pending(app) && app.pcrop.doc.is_some() && app.pcrop.doc != doc { cancel(app); @@ -130,7 +131,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { } else { app.pcrop.clicks.clear(); let (x0, y0, x1, y1) = (start[0].min(cur[0]), start[1].min(cur[1]), start[0].max(cur[0]), start[1].max(cur[1])); - app.ui.perspective_crop = Some([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]); + // A straight drag has no area: no box (it could neither be committed nor + // replaced by a new drag). + app.ui.perspective_crop = (x1 > x0 && y1 > y0).then_some([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]); } } app.pcrop.drag = None; @@ -157,13 +160,15 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2]) { /// `image.perspectiveCrop`'s size params from W, H and the resolution (the Crop tool's fields): /// lengths in physical units convert at the typed resolution, else the document's. -fn size_params(app: &PhotocraftApp, p: &mut Value) { +pub(crate) fn size_params(app: &PhotocraftApp, p: &mut Value) { let o = &app.ui.tool_options; let res = crate::crop_size::resolution_ppi(o); let dpi = res.unwrap_or_else(|| app.session.active().map_or(72.0, |st| f64::from(st.doc.resolution_dpi))); + // Points and picas per Preferences › Point/Pica Size. + let pt_per_in = app.session.prefs().units_and_rulers.point_size.per_inch(); let px = |text: &str| { let (v, u) = crate::crop_size::parse_length(text, Unit::Pixels)?; - let px = u.to_px(v, dpi, 0.0, 72.0).round(); + let px = u.to_px(v, dpi, 0.0, pt_per_in).round(); (px.is_finite() && px >= 1.0).then_some(px) }; if let Some(w) = px(&o.crop_width) { @@ -299,6 +304,29 @@ mod tests { use super::*; use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; + /// A straight drag (no width or no height) leaves no zero-area box that only Esc could clear, + /// and a plain click still counts as a corner click. + #[test] + fn a_drag_without_area_leaves_no_quad() { + let doc = Document::with_background("p", Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::PerspectiveCrop; + let ev = |app: &mut PhotocraftApp, e| assert!(pointer(app, e)); + for (x1, y1) in [(10.0, 60.0), (90.0, 10.0)] { + ev(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: x1, y: y1, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: x1, y: y1 }); + assert_eq!(app.ui.perspective_crop, None, "drag to ({x1}, {y1})"); + assert!(app.pcrop.clicks.is_empty()); + } + ev(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 30.0, y: 30.0 }); + assert_eq!(app.pcrop.clicks, vec![[30.0, 30.0]]); + assert_eq!(app.ui.perspective_crop, None); + } + /// A box drag makes the quad, corners drag alone, and ↵'s commit crops the document to the /// quad's size; four clicks in any order make a quad in TL, TR, BR, BL order. #[test] @@ -323,4 +351,27 @@ mod tests { assert_eq!(ordered([[10.0, 90.0], [100.0, 5.0], [0.0, 0.0], [90.0, 100.0]]), [[0.0, 0.0], [100.0, 5.0], [90.0, 100.0], [10.0, 90.0]]); } + + /// The pending quad belongs to its document (#1918): switching documents drops it, so it is + /// never drawn on the other one; on its own document it stays. + #[test] + fn switching_documents_drops_the_pending_quad() { + let mut s = photocraft_engine::Session::new(); + for name in ["a", "b"] { + s.add_document(Document::with_background(name, Size::new(200, 150), ColorMode::Rgb, SampleType::U8, Color::WHITE), None); + } + s.set_active(0); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.ui.tool = Tool::PerspectiveCrop; + let ev = |app: &mut PhotocraftApp, e| assert!(pointer(app, e)); + ev(&mut app, ToolEvent::Down { x: 20.0, y: 10.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: 120.0, y: 90.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 120.0, y: 90.0 }); + app.sync_views(); + assert!(app.ui.perspective_crop.is_some(), "same document: the quad stays"); + app.session.set_active(1); + app.sync_views(); + assert_eq!(app.ui.perspective_crop, None); + assert!(app.pcrop.drag.is_none() && app.pcrop.clicks.is_empty()); + } } diff --git a/crates/ui-egui/src/perspective_ui.rs b/crates/ui-egui/src/perspective_ui.rs index 8afe1113dc..e39a791d4f 100644 --- a/crates/ui-egui/src/perspective_ui.rs +++ b/crates/ui-egui/src/perspective_ui.rs @@ -255,7 +255,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { } } -pub fn draw(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { +/// The warped pixels: image content, drawn under the grid and guides like the document so a +/// guide stays visible across the layer being warped (#7781). The planes and pins are [`draw`]. +pub fn draw_content(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); let mut mesh = egui::Mesh::with_texture(s.texture.id()); let tint = Color32::from_white_alpha((s.opacity.clamp(0.0, 1.0) * 255.0) as u8); @@ -273,6 +275,11 @@ pub fn draw(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { } } painter.add(mesh); +} + +/// The planes, their grids and pins over the warped pixels ([`draw_content`]) and the guides. +pub fn draw(s: &PerspSession, painter: &egui::Painter, xf: &ViewXform) { + let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); let accent = crate::theme::Tokens::get(painter.ctx()).accent; let warp = s.mode == PerspMode::Warp; for p in &s.planes { diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index 62975e2d73..6101436a17 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -138,6 +138,45 @@ fn double_click_closes_the_polygon_after_quickly_placed_vertices() { assert!(selection(&h).is_some_and(|r| near(r, 50, 50, 150, 150)), "{:?}", selection(&h)); } +/// The Lasso's ⌥ (straight-segment) mode could not draw a segment shorter than the radius for +/// grabbing the first vertex: the click that should have placed the next vertex counted as the +/// last one clicked again and closed the outline, so a small outline could not be traced at all +/// (#7629). Clicking the same point again still closes it, within a jitter of where it is. +#[test] +fn a_short_alt_lasso_segment_places_a_vertex_instead_of_closing() { + let mut app = polygon_app(); + app.ui.tool = Tool::Lasso; + let alt = Modifiers::ALT; + let click = |app: &mut PhotocraftApp, x: f64, y: f64| { + ev(app, "down", x, y, alt); + ev(app, "up", x, y, alt); + }; + for (x, y) in [(50.0, 50.0), (90.0, 50.0), (90.0, 90.0)] { + click(&mut app, x, y); + } + // 5 document units from the last vertex, inside the 8-unit reach at 100 %. + click(&mut app, 85.0, 91.0); + let points = app.drag.as_ref().map(|d| d.points.len()); + assert_eq!(points, Some(4), "the short segment placed a vertex: {:?}", app.drag.as_ref().map(|d| d.points.clone())); + assert!(app.session.active().unwrap().doc.selection.is_none(), "and the outline is still being drawn"); + + // Clicking the last vertex again, within a jitter of it, still closes the outline. + click(&mut app, 86.0, 92.0); + assert!(app.drag.is_none(), "the same point clicked again closes it"); + let r = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert!(r.is_some_and(|r| near(r, 50, 50, 90, 91)), "{r:?}"); + + // And so does a click on the first vertex, which is a grab rather than a repeat. + let mut app = polygon_app(); + app.ui.tool = Tool::Lasso; + for (x, y) in [(50.0, 50.0), (90.0, 50.0), (90.0, 90.0), (85.0, 91.0)] { + click(&mut app, x, y); + } + click(&mut app, 54.0, 53.0); + assert!(app.drag.is_none(), "the first vertex closes it from a few points away"); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} + /// Double-clicking on the first vertex: the first click closes the polygon, and the second must /// not start a new one (which would deselect what was just made). #[test] diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 5cd2e547d8..9123e499e2 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -15,7 +15,7 @@ use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome, Started}; #[cfg(test)] use photocraft_engine::prefs::Theme; use photocraft_engine::prefs::{self, AppearanceMode, DarkTheme, LightTheme, SECTIONS, Theme as SavedTheme}; -use photocraft_engine::snap::{SnapLine, SnapTargets}; +use photocraft_engine::snap::{SnapLine, SnapOptions, SnapTargets}; use serde_json::{Map, Value, json}; use crate::PhotocraftApp; @@ -43,7 +43,7 @@ pub struct Runtime { /// Snapping state of the drag in progress (see `snap_ui`). pub(crate) snap: Option, pub(crate) snap_lines: Vec, - pub(crate) guide_targets: Option<((DocId, u64), SnapTargets)>, + pub(crate) guide_targets: Option<((DocId, u64, SnapOptions), SnapTargets)>, /// Checkerboard colours the cached CPU checker texture was built with. pub(crate) checker_key: Option<[[u8; 3]; 2]>, /// Style last sent to the GPU canvas. @@ -53,6 +53,8 @@ pub struct Runtime { /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, pub(crate) selected_tool: Option, + /// The tool owning the session brush and the other tools' brushes, as last remembered. + pub(crate) tool_brushes: Option, } /// The GPU canvas colours from Preferences › Transparency & Gamut. @@ -894,6 +896,75 @@ pub fn is_modifier_key(key: egui::Key) -> bool { matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight) } +/// Modifier bits a physical modifier-key press contributes when egui leaves them out of +/// [`Modifiers`] on the key event (#7190). +fn modifier_key_modifiers(key: egui::Key) -> egui::Modifiers { + use egui::Key::*; + match key { + ShiftLeft | ShiftRight => egui::Modifiers::SHIFT, + ControlLeft | ControlRight => egui::Modifiers::CTRL, + AltLeft | AltRight => egui::Modifiers::ALT, + SuperLeft | SuperRight => egui::Modifiers::COMMAND, + _ => egui::Modifiers::NONE, + } +} + +fn capture_mods_field(v: Option<&Value>) -> egui::Modifiers { + let o = v.and_then(Value::as_object); + egui::Modifiers { + shift: o.and_then(|o| o.get("shift")).and_then(Value::as_bool).unwrap_or(false), + ctrl: o.and_then(|o| o.get("ctrl")).and_then(Value::as_bool).unwrap_or(false), + alt: o.and_then(|o| o.get("alt")).and_then(Value::as_bool).unwrap_or(false), + command: o.and_then(|o| o.get("command")).and_then(Value::as_bool).unwrap_or(false), + mac_cmd: o.and_then(|o| o.get("mac_cmd")).and_then(Value::as_bool).unwrap_or(false), + } +} + +fn capture_mods_json(m: egui::Modifiers) -> Value { + json!({ + "shift": m.shift, + "ctrl": m.ctrl, + "alt": m.alt, + "command": m.command, + "mac_cmd": m.mac_cmd, + }) +} + +/// While capture is active: refresh held modifiers (including across frames via dialog +/// `captureMods`) and, if a non-modifier key was pressed this frame, return it with the +/// modifiers held with it. +/// +/// #7190 root cause: capture used to [`find_map`](Iterator::find_map) the first non-modifier +/// [`Event::Key`] and pass only that event's `modifiers` into [`shortcut_text`]. On Linux and +/// Windows, winit/egui often reports `ShiftLeft`/`ShiftRight` (and sometimes the letter key) with +/// [`Modifiers::NONE`], and the modifier down and the letter down often land in separate UI +/// frames. Shift was then missing from the stored binding (`F6` instead of `Shift+F6`). Like #292 +/// for `ControlLeft`, we walk the frame's events, OR in [`modifier_key_modifiers`] for modifier +/// keys, and persist accumulated modifiers in `captureMods` until capture ends. +pub(crate) fn capture_key_press(capture_mods: egui::Modifiers, i: &egui::InputState) -> (egui::Modifiers, Option<(egui::Key, egui::Modifiers)>) { + let mut mods = capture_mods | i.modifiers; + let mut pressed = None; + for e in &i.events { + if let egui::Event::ModifiersChanged(m) = e { + mods = *m; + } + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + if is_modifier_key(*key) { + mods = *modifiers | modifier_key_modifiers(*key); + continue; + } + pressed = Some((*key, mods | *modifiers)); + break; + } + if let egui::Event::Key { key, pressed: false, modifiers, .. } = e + && is_modifier_key(*key) + { + mods = *modifiers; + } + } + (mods, pressed) +} + // ------------------------------------------------------------------ menu routing /// Shell front ends for Edit-menu commands invoked without parameters (dialogs, pickers). @@ -1157,6 +1228,10 @@ fn humanize(key: &str) -> String { } // Performance: presentation without vsync (#1343); like the other graphics settings, it // takes effect when the window next opens. + // File Handling: Photoshop's wording (#8080). + if key == "doNotAppendCopy" { + return "Do not append \"copy\" to filename when saving a copy".into(); + } if key == "lowLatencyDrawing" { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } @@ -1175,11 +1250,18 @@ fn humanize(key: &str) -> String { s.push(ch); } } - s.replace("Psd", "PSD").replace("Gpu", "GPU").replace("Ui ", "UI ").replace("Mb", "(MB)").replace("Exif", "EXIF").replace("Hud", "HUD") + s.replace("Psd", "PSD") + .replace("Gpu", "GPU") + .replace("Ui ", "UI ") + .replace("Mb", "(MB)") + .replace("Exif", "EXIF") + .replace("Hud", "HUD") + .replace("Lut ", "LUT ") } fn choice_label(v: &str) -> String { match v { + "exr" => "EXR".into(), "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), "75" | "80" | "85" | "90" | "95" | "100" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"), @@ -1191,6 +1273,8 @@ fn choice_label(v: &str) -> String { "dx12" => "DirectX 12".into(), "metal" => "Metal".into(), "gl" => "OpenGL".into(), + "ahd" => "AHD (adaptive homogeneity)".into(), + "mhc" => "Malvar–He–Cutler".into(), "cpu" => "CPU (no GPU acceleration)".into(), v => humanize(v), } @@ -1413,6 +1497,9 @@ fn rendering_mode_row(ui: &mut egui::Ui, obj: &mut Map) { } }); ui.label(tl!("Automatic uses GPU acceleration when available and falls back to CPU rendering on errors.")); + // Next to the setting, as for System Title Bar (#1677): the renderer is picked when the + // window opens, so a change otherwise looks like it did nothing (#7939). + ui.label(RichText::new(tl!("Applies at next launch.")).color(Tokens::get(ui.ctx()).text_dim)); ui.add_space(8.0); } @@ -1435,6 +1522,7 @@ fn has_visible_fields(values: &Value, section: &str) -> bool { fn export_field_visible(obj: &Map, key: &str) -> bool { let format = obj.get("quickExportFormat").and_then(Value::as_str).unwrap_or("png"); match key { + "convertToSrgb" | "metadata" => format != "exr", "jpegQuality" => format == "jpg", "webpLossless" => format == "webp", "webpQuality" => format == "webp" && obj.get("webpLossless").and_then(Value::as_bool) != Some(true), @@ -1485,8 +1573,11 @@ fn theme_swatch(ui: &mut egui::Ui, kind: ThemeKind, id: &str, selected: &mut Str ui.painter().circle(center, BADGE, t.card, egui::Stroke::new(1.0, t.card_border)); let (icon, tint) = if favorite { ("star-filled", t.accent) } else { ("star", t.text_dim) }; let action = if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }; - let star = egui::Button::image(crate::icons::image(icon, 10.0, tint)).frame(false); - let response = ui.put(badge, star).on_hover_text(action); + // Interact and paint without allocating: a widget placed at `badge` would grow the row by the + // 4 px the badge overhangs the swatch, re-centring the swatches after this one whenever the + // pointer arrives, and oscillating when that moves the swatch out from under it. + let response = ui.interact(badge, ui.id().with((id, "favorite")), Sense::click()).on_hover_text(action); + crate::icons::paint(ui, badge, icon, 10.0, tint); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{action}: {label}"))); if response.clicked() { if favorite { @@ -1718,7 +1809,7 @@ fn field_rows(ui: &mut egui::Ui, section: &str, obj: &mut Map, ke Value::String(s) if path == "interface.language" => { ui.label(RichText::new(tl!(&label)).color(t.text_dim)); let mut pairs: Vec<(String, &str)> = vec![("auto".into(), tl!("Auto"))]; - pairs.extend(crate::i18n::Lang::all().map(|l| (l.code().to_string(), l.name()))); + pairs.extend(crate::i18n::Lang::for_picker().into_iter().map(|l| (l.code().to_string(), l.name()))); let mut cur = s.clone(); crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); obj.insert(k, json!(cur)); @@ -1873,14 +1964,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Some((*key, *modifiers)), - _ => None, - }) - }); + let (next_mods, pressed) = ui.input(|i| capture_key_press(capture_mods, i)); + capture_mods = next_mods; if let Some((key, m)) = pressed { ui.input_mut(|i| { i.consume_key(m, key); @@ -2160,6 +2248,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map *s != now, - None => now != ShortcutSet::default(), - }; + // The engine decides what a set holds and when it is modified (not the actions' F-keys). + let now = ShortcutSet::from_bindings(overrides, alternates); + let modified = shortcut_sets::differs(&sets, &active, overrides, alternates); let defaults = tl!("PhotoCraft Defaults").to_string(); let shown = |name: &str| -> String { let base = if name.is_empty() { defaults.clone() } else { name.to_string() }; @@ -2222,9 +2313,8 @@ fn set_row( let mut chosen = active.clone(); let pairs: Vec<(String, &str)> = names.iter().map(|(n, l)| (n.clone(), l.as_str())).collect(); if crate::widgets::dropdown(ui, "shortcut-set", &mut chosen, &pairs, 220.0) && chosen != active { - let set = sets.get(&chosen).cloned().unwrap_or_default(); - *overrides = set.shortcuts; - *alternates = set.alternate_shortcuts; + // As `useSet`: the set's bindings, keeping the actions' F-keys. + sets.get(&chosen).cloned().unwrap_or_default().apply(overrides, alternates); active = chosen; message.clear(); } @@ -2640,6 +2730,10 @@ mod tests { assert!(export_field_visible(&obj, "webpQuality")); assert_eq!(humanize("webpLossless"), "Lossless"); assert_eq!(humanize("webpQuality"), "Quality"); + obj.insert("quickExportFormat".into(), json!("exr")); + for key in ["jpegQuality", "webpLossless", "webpQuality", "convertToSrgb", "metadata"] { + assert!(!export_field_visible(&obj, key), "EXR must not offer ignored web settings: {key}"); + } } #[test] @@ -3041,6 +3135,27 @@ mod tests { assert_eq!(dark, vec![ThemeKind::Studio]); } + /// The favourite star badge overhangs its swatch; showing it on hover must not move the swatches + /// after it, or the row would shift under the pointer. + #[test] + fn theme_star_badge_on_hover_does_not_move_the_next_swatch() { + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, _) = app_with_store(); + open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let first = h.get_by_label("Classic").rect(); + let next = h.get_by_label("Adwaita").rect(); + h.hover_at(first.right_top() + vec2(-3.0, 3.0)); + h.run_steps(3); + assert!(h.query_by_label("Add to favorites: Classic").is_some(), "the badge shows while the swatch is hovered"); + assert_eq!(h.get_by_label("Classic").rect(), first); + assert_eq!(h.get_by_label("Adwaita").rect(), next); + } + #[test] fn preferences_interface_shows_language_without_scrolling() { // #2532: in a small window the theme cards filled the page and Language sat out of view @@ -3557,6 +3672,196 @@ mod tests { } } + /// #6212: a remembered value its options-bar field could never hold is clamped to the + /// field's range (or takes the default) rather than reaching the tool. + #[test] + fn remembered_tool_options_are_clamped_to_their_fields() { + let ctx = egui::Context::default(); + let hostile = json!({"toolOptions": { + "feather": 1e30, "tolerance": -5.0, "polygon_sides": 0, "eyedropper_size": 7, + "crop_overlay_orientation": 9, "fill_opacity": 0.0, "magnetic_width": 1e9, + "pattern_stamp_angle": -720.0, "marquee_width": 1e7, "shape_stroke": {"miter_limit": -1.0, "opacity": 5.0}, + }}); + let (mut app, _) = app_with_saved(Some(hostile.to_string())); + tick(&mut app, &ctx); + let o = &app.ui.tool_options; + let d = crate::state::ToolOptions::default(); + assert_eq!((o.feather, o.tolerance, o.polygon_sides, o.eyedropper_size), (1000.0, 0.0, 3, d.eyedropper_size)); + assert_eq!((o.crop_overlay_orientation, o.fill_opacity, o.magnetic_width), (d.crop_overlay_orientation, 1.0, 256.0)); + assert_eq!((o.pattern_stamp_angle, o.marquee_width), (-180.0, 999.0)); + assert_eq!(o.shape_stroke, d.shape_stroke, "an invalid stroke style takes the default"); + // A good value is left alone, and in-range defaults never change (nothing to save). + let (mut app2, _) = app_with_saved(Some(json!({"toolOptions": {"feather": 12.5}}).to_string())); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions { feather: 12.5, ..d.clone() }); + let mut def = d.clone(); + crate::tool_memory::sanitize(&mut def); + assert_eq!(def, d); + } + + /// #6212: every painting tool keeps its own brush across a restart, and the session brush + /// comes back as the brush of the tool that had it. + #[test] + fn tool_brushes_survive_a_restart() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + let set_size = |app: &mut PhotocraftApp, size: f32| { + app.session.tools.brush.size = size; + app.session.tools.brush.opacity = size / 100.0; + app.session.tools.brush_changed(); + }; + set_size(&mut app, 77.0); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + set_size(&mut app, 33.0); + app.ui.tool = Tool::CloneStamp; + crate::paint_mouse::sync_tool_brush(&mut app); + set_size(&mut app, 12.0); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolBrushes"]["owner"], json!("Eraser")); + assert_eq!(saved["toolBrushes"]["brushes"]["Brush"]["size"], json!(77.0)); + assert_eq!(saved["toolBrushes"]["brushes"]["CloneStamp"]["size"], json!(12.0)); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_none(), "the owner's brush is the last brush"); + + let (mut app2, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut app2, &ctx); + assert_eq!((app2.ui.tool, app2.ui.brush_tool), (Tool::Eraser, Tool::Eraser)); + assert_eq!(app2.session.tools.brush.size, 33.0, "the Eraser's brush is the session brush"); + for (tool, size) in [(Tool::Brush, 77.0), (Tool::CloneStamp, 12.0), (Tool::Eraser, 33.0)] { + app2.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app2); + assert_eq!(app2.session.tools.brush.size, size, "{tool:?}"); + assert_eq!(app2.session.tools.brush.opacity, size / 100.0, "{tool:?}"); + } + } + + /// #6212: `toolBrushes.owner` names the tool whose brush `last_brush` holds. A brush that + /// can't be remembered yet (a sampled tip too big to embed) leaves `last_brush` at the old + /// owner's brush, so the old owner stays saved: the next launch gives that brush back to it, + /// not to the tool selected since. + #[test] + fn the_saved_brush_owner_follows_the_saved_last_brush() { + use crate::state::Tool; + use photocraft_engine::paint::{GrayTile, TipShape}; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + app.session.tools.brush.size = 77.0; + app.session.tools.brush_changed(); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + // The Brush's tip becomes a big sampled one, then the Eraser takes over that brush. + app.session.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(400, 300, |x, y| ((x * y) % 7) as f32 / 7.0)); + app.session.tools.brush_changed(); + app.ui.tool = Tool::Eraser; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["lastBrush"]["brush"]["size"], json!(77.0), "the big tip is not remembered yet"); + assert_ne!(saved["toolBrushes"]["owner"], json!("Eraser"), "{saved}"); + let (mut app2, _) = app_with_saved(store.lock().unwrap().clone()); + tick(&mut app2, &ctx); + assert_eq!(app2.ui.brush_tool, Tool::Brush, "the 77 px brush is the Brush's"); + assert_eq!(app2.session.tools.brush.size, 77.0); + + // Once the Eraser's brush is saved, the Eraser owns it in the preferences too. + app.session.tools.brush.tip = TipShape::Round; + app.session.tools.brush.size = 33.0; + app.session.tools.brush_changed(); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolBrushes"]["owner"], json!("Eraser"), "{saved}"); + assert_eq!(saved["lastBrush"]["brush"]["size"], json!(33.0)); + } + + /// #6212: a damaged or hand-edited `toolBrushes` never panics: what reads back loads (clamped + /// to the options bar's ranges), the rest is skipped and those tools start from the brush. + #[test] + fn remembered_tool_brushes_survive_damage() { + use crate::state::Tool; + let ctx = egui::Context::default(); + for bad in [json!("junk"), json!(42), json!({"owner": [1], "brushes": "x"}), json!({"brushes": {"Eraser": 5}})] { + let (mut app, _) = app_with_saved(Some(json!({"toolBrushes": bad}).to_string())); + tick(&mut app, &ctx); + assert_eq!(app.ui.brush_tool, Tool::Brush, "{bad}"); + assert!(app.ui.tool_brushes.is_empty(), "{bad}"); + } + let hostile = json!({"toolBrushes": {"owner": "Move", "brushes": { + "Eraser": {"size": 1e30}, "Bogus": {"size": 5.0}, "Move": {"size": 5.0}, + "Smudge": {"size": -3.0, "opacity": 50.0, "flow": -2.0, "hardness": 1e30}, + "Brush": {"size": 9.0}, + }}}); + let (mut app, _) = app_with_saved(Some(hostile.to_string())); + tick(&mut app, &ctx); + assert_eq!(app.ui.brush_tool, Tool::Brush, "the Move tool has no brush"); + let tools: Vec = app.ui.tool_brushes.iter().map(|(t, _)| *t).collect(); + assert_eq!(tools, vec![Tool::Smudge], "too large, unknown, brushless and the owner's own are skipped"); + let b = &app.ui.tool_brushes[0].1; + assert_eq!((b.size, b.opacity, b.flow, b.hardness), (1.0, 1.0, 0.0, 1.0)); + // A whole preferences file that isn't JSON: defaults, no panic. + let (mut app, _) = app_with_saved(Some("{not json".into())); + tick(&mut app, &ctx); + assert!(app.ui.tool_brushes.is_empty()); + assert_eq!(app.ui.tool_options, crate::state::ToolOptions::default()); + } + + /// #6212: Reset Tool forgets that tool's remembered options and brush; Reset All Tools + /// forgets every tool's. + #[test] + fn reset_tools_clear_the_remembered_options_and_brushes() { + use crate::state::Tool; + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + app.ui.tool_options.feather = 12.0; + app.ui.tool_options.strength = 80.0; + app.session.tools.brush.size = 77.0; + app.session.tools.brush_changed(); + for tool in [Tool::Eraser, Tool::Smudge, Tool::Brush] { + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + } + app.ui.tool = Tool::Smudge; + crate::paint_mouse::sync_tool_brush(&mut app); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert_eq!(saved["toolOptions"]["strength"], json!(80.0)); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_some() && saved["toolBrushes"]["brushes"].get("Brush").is_some()); + + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET, json!({})).unwrap(); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert!(saved["toolOptions"].get("strength").is_none(), "the Smudge's Strength is forgotten"); + assert_eq!(saved["toolOptions"]["feather"], json!(12.0), "the marquee's Feather is not the Smudge's"); + assert!(saved["toolBrushes"]["brushes"].get("Eraser").is_some()); + + crate::menus::invoke(&mut app, &ctx, crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap(); + crate::last_brush::persist(&mut app, &ctx); + crate::tool_memory::persist(&mut app, &ctx); + tick(&mut app, &ctx); + let saved = stored(&store); + assert!(saved["toolOptions"].is_null(), "{saved}"); + assert!(saved["toolBrushes"]["brushes"].as_object().is_none_or(|m| m.is_empty()), "{saved}"); + let (app2, _) = app_with_saved(store.lock().unwrap().clone()); + assert_eq!(app2.ui.tool_options, crate::state::ToolOptions::default()); + assert!(app2.ui.tool_brushes.is_empty()); + } + #[test] fn view_show_items_survive_a_restart() { let (mut app, store) = app_with_store(); @@ -3590,6 +3895,26 @@ mod tests { } } + #[test] + fn view_snap_off_survives_a_restart() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + assert!(app.ui.extras.snap, "on by default"); + crate::menus::invoke(&mut app, &ctx, "view.snap", json!({})).unwrap(); + assert!(!app.ui.extras.snap); + crate::view_cmds::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["viewExtras"], json!({"snap": false})); + let saved = store.lock().unwrap().clone().unwrap(); + let (mut app2, _) = app_with_saved(Some(saved)); + tick(&mut app2, &ctx); + assert!(!app2.ui.extras.snap, "still off after a restart"); + crate::menus::invoke(&mut app2, &ctx, "view.snap", json!({})).unwrap(); + crate::view_cmds::persist(&mut app2, &ctx); + assert!(app2.session.prefs().view_extras.is_null()); + } + #[test] fn show_tooltips_preferences_turn_tooltips_off() { let (mut app, _) = app_with_store(); @@ -3653,11 +3978,14 @@ mod tests { assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); assert!(!prefs::is_hidden("enhancedControls.touchGestures")); assert!(prefs::is_hidden("enhancedControls.scrubbySliderAcceleration")); - // Camera Raw Defaults shows only "Open in Camera Raw" so far. + // Camera Raw Defaults shows "Open in Camera Raw" and the demosaic method so far. assert!(has_visible_fields(&values, "rawDefaults")); assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); + assert!(!prefs::is_hidden("rawDefaults.demosaic")); assert!(prefs::is_hidden("rawDefaults.applyAutoTone")); assert!(!prefs::is_hidden("general.autoShowHomeScreen")); + assert!(!prefs::is_hidden("general.animatedZoom")); + assert!(!prefs::is_hidden("general.zoomResizesWindows")); assert!(!prefs::is_hidden("interface.uiScale")); assert!(!prefs::is_hidden("interface.uiFontSize")); // Hidden values still round-trip through the dialog untouched. @@ -3859,6 +4187,28 @@ mod tests { assert!(h.state().custom_titlebar); } + /// #7939: switching Rendering Mode waits for the next launch, and says so next to the setting. + #[test] + fn rendering_mode_explains_next_launch() { + use egui_kittest::{Harness, kittest::Queryable}; + let obj = json!({"renderingMode": "auto", "historyStates": 50}).as_object().unwrap().clone(); + let mut h = Harness::new_ui_state( + |ui, obj: &mut Map| section_fields(ui, "performance", obj, &[], crate::i18n::Lang::from_pref("en"), None, ""), + obj, + ); + h.run_steps(4); + h.get_by_label("Rendering Mode"); + h.get_by_label("Applies at next launch."); + // A search that leaves Rendering Mode out leaves its notice out too. + let obj = json!({"renderingMode": "auto", "historyStates": 50}).as_object().unwrap().clone(); + let mut h = Harness::new_ui_state( + |ui, obj: &mut Map| section_fields(ui, "performance", obj, &[], crate::i18n::Lang::from_pref("en"), None, "history"), + obj, + ); + h.run_steps(4); + assert!(h.query_by_label("Applies at next launch.").is_none()); + } + #[test] fn next_launch_notice_does_not_mark_unrelated_interface_preferences() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index 1ea0725838..85a2a08653 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -1,7 +1,7 @@ //! Preset files opened like documents (File › Open, drag-and-drop, the Brushes panel's //! Import Brushes…, the Preset Manager's Load…): Photoshop brushes (`.abr`) go to the brush //! library through `brush.presets.importAbr`, gradients (`.grd`) to the Gradients panel through -//! `gradient.presets.importGrd`, swatches (`.aco`, `.ase`) to the Swatches panel through +//! `gradient.presets.importGrd`, swatches (`.aco`, `.act`, `.ase`) to the Swatches panel through //! `swatches.import`, patterns (`.pat`) to the Patterns panel through `pattern.import`, keyboard //! shortcut sets (`.kys`) to Edit › Keyboard Shortcuts through //! [`crate::kys_import`], instead of opening as documents. Import Brushes… also takes an image @@ -11,11 +11,11 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; -/// Extensions handled here rather than by the document importer (`.aco`/`.ase` swatches go to +/// Extensions handled here rather than by the document importer (`.aco`/`.act`/`.ase` swatches go to /// the Swatches panel through [`crate::swatches_ui::import_bytes`]). /// `.psp` is Photoshop's live `Keyboard Shortcuts.psp` (the same XML as a `.kys`); other `.psp` /// settings files are refused with a clear message. -pub const PRESET_EXTS: &[&str] = &["abr", "grd", "pat", "aco", "ase", "kys", "psp"]; +pub const PRESET_EXTS: &[&str] = &["abr", "grd", "pat", "aco", "act", "ase", "kys", "psp"]; fn ext(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() @@ -89,7 +89,7 @@ pub fn open(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Option ("brush.presets.importAbr", "brushes"), "grd" => ("gradient.presets.importGrd", "gradients"), "pat" => ("pattern.import", "patterns"), - "aco" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), + "aco" | "act" | "ase" => return Some(crate::swatches_ui::import_bytes(app, name, bytes, false).map(|_| ())), "kys" | "psp" => return Some(crate::kys_import::open_bytes(app, name, bytes).map(|_| ())), _ => return None, }; diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index b52f1400d7..e2ca35a2f9 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -1453,7 +1453,18 @@ mod tests { fn preset_drops_map_through_the_ruler_inset() { let (mut app, _) = app(); app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); - app.ui.views[0] = crate::state::View { zoom: 2.0, center: [32.0, 24.0], fit_pending: false, fill_pending: false, doc_size: [64, 48], rotation: 0.0 }; + app.ui.views[0] = crate::state::View { + zoom: 2.0, + center: [32.0, 24.0], + fit_pending: false, + fill_pending: false, + doc_size: [64, 48], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; let p = egui::pos2(400.0, 300.0); let off = crate::canvas::doc_point_at(&app, p).unwrap(); assert_eq!(off, [32.0, 24.0], "without rulers the widget rect is the whole mapping"); diff --git a/crates/ui-egui/src/press_menu.rs b/crates/ui-egui/src/press_menu.rs index 7110e833b4..639060903c 100644 --- a/crates/ui-egui/src/press_menu.rs +++ b/crates/ui-egui/src/press_menu.rs @@ -79,6 +79,22 @@ impl PressMenu { } } +/// Keep an open popup or dropdown layer above `parent` when `parent` is also in +/// [`egui::Order::Foreground`] (a modal dialog or popover, #7113): a 1-frame trackpad tap raises +/// both the parent modal (`pointer_pressed_on_area`) and the reopened popup (`!visible_last_frame`) +/// in the same pass, and egui's stable `wants_to_be_on_top` sort then leaves a previously used +/// popup behind the dialog. +pub fn above_parent(ctx: &egui::Context, parent: egui::LayerId, child: egui::LayerId) { + if parent.order == child.order && parent != child { + ctx.set_sublayer(parent, child); + } +} + +/// [`above_parent`] for an open popup's `Ui`. +pub fn above_parent_ui(parent: egui::LayerId, popup_ui: &egui::Ui) { + above_parent(popup_ui.ctx(), parent, popup_ui.layer_id()); +} + /// Shows `button`'s menu (an `egui::Popup::menu`) with press-drag-release; `content` chooses its /// items with [`PressMenu::item`]. pub fn show(button: &Response, content: impl FnOnce(&mut Ui, &PressMenu) -> R) -> Option { @@ -89,6 +105,9 @@ pub fn show(button: &Response, content: impl FnOnce(&mut Ui, &PressMenu) -> R // close it again. let close = if button.clicked() && menu.owns_gesture(&ctx) { PopupCloseBehavior::IgnoreClicks } else { PopupCloseBehavior::CloseOnClick }; let shown = Popup::menu(button).open_memory(open).close_behavior(close).show(|ui| content(ui, &menu)).map(|r| r.inner); + if shown.is_some() || Popup::is_id_open(&ctx, menu.popup) { + above_parent(&ctx, button.layer_id, egui::LayerId::new(egui::Order::Foreground, menu.popup)); + } // This menu's gesture ends with the button (its release was handled by the items above). if menu.owns_gesture(&ctx) && ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { ctx.data_mut(|d| d.remove::(gesture_id())); @@ -150,6 +169,9 @@ impl PressCombo { } else if self.owns_gesture(ctx) && ctx.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) { ctx.data_mut(|d| d.remove::(gesture_id())); } + if Popup::is_id_open(ctx, self.popup) { + above_parent(ctx, button.layer_id, egui::LayerId::new(egui::Order::Foreground, self.popup)); + } } } diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 3eeb06b003..42053729f4 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -13,7 +13,10 @@ use photocraft_doc::{Color, ColorMode, Document, SampleType, Size}; use photocraft_engine::Session; use serde_json::Value; -const USE: &str = "Use the embedded profile (Adobe RGB (1998)-compatible (PhotoCraft))"; +/// The embedded-profile choice uses the current built-in profile's displayed name. +fn use_embedded() -> String { + format!("Use the embedded profile ({})", photocraft_cms::Builtin::AdobeRgbCompat.description()) +} const CONVERT: &str = "Convert to the working space (sRGB IEC61966-2.1)"; const DISCARD: &str = "Discard the embedded profile (don't color manage)"; @@ -30,8 +33,8 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { PhotocraftApp::new( Session::new(), Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }, @@ -125,7 +128,7 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { assert_eq!(docs(&h), answered, "background {background}: a file opens only after its prompt"); let name = asked(&h); assert!(!answered.contains(&name), "background {background}: {name} asked twice"); - answer(&mut h, USE); + answer(&mut h, &use_embedded()); answered.push(name); } in_order(background, &answered); @@ -168,8 +171,8 @@ fn the_prompt_is_translated_and_still_names_its_file() { ), ] { let services = Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }; @@ -204,7 +207,8 @@ fn a_held_file_still_reports_its_missing_fonts() { s.execute("type.create", serde_json::json!({"text": "Hi", "x": 2, "y": 12, "size": 10, "font": MISSING})).unwrap(); let mut doc = (*s.active().unwrap().doc).clone(); doc.icc_profile = Some(photocraft_cms::Builtin::from_id("adobe-rgb-compat").unwrap().profile().to_bytes()); - let services = Services { import: Some(Box::new(move |_: &str, _: &[u8], _: usize| Ok((doc.clone(), Vec::new())))), ..Default::default() }; + let services = + Services { import: Some(Box::new(move |_: &str, _: &[u8], _: &photocraft_io::ImportOptions| Ok((doc.clone(), Vec::new())))), ..Default::default() }; let mut app = PhotocraftApp::new(Session::new(), services); app.run("edit.colorSettings", serde_json::json!({"askOnMismatch": true, "policyRgb": "preserve"})).unwrap(); assert_eq!(app.open_bytes("poster.psd", b"x").unwrap(), vec![warning.clone()]); @@ -217,6 +221,7 @@ fn a_held_file_still_reports_its_missing_fonts() { #[test] fn each_answer_applies_to_its_own_file() { + let use_embedded = use_embedded(); for background in [false, true] { let mut h = harness(background); open_three(&mut h); @@ -229,7 +234,7 @@ fn each_answer_applies_to_its_own_file() { match name { "a.png" => CONVERT, "b.png" => DISCARD, - _ => USE, + _ => &use_embedded, }, ); answered.push(name); @@ -249,7 +254,7 @@ fn cancel_opens_neither_the_file_nor_the_ones_still_to_come() { open_three(&mut h); let first = asked(&h); in_order(background, &[first]); - answer(&mut h, USE); + answer(&mut h, &use_embedded()); click(&mut h, "Cancel"); settle(&mut h); assert!(h.state().ui.dialogs.is_empty(), "background {background}: no prompt left"); diff --git a/crates/ui-egui/src/profile_prompt_tests.rs b/crates/ui-egui/src/profile_prompt_tests.rs index e055cdd546..1aa56eaac7 100644 --- a/crates/ui-egui/src/profile_prompt_tests.rs +++ b/crates/ui-egui/src/profile_prompt_tests.rs @@ -16,8 +16,8 @@ fn harness(background: bool) -> Harness<'static, PhotocraftApp> { PhotocraftApp::new( Session::new(), Services { - import: Some(Box::new(|name, bytes, depth| { - photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) + import: Some(Box::new(|name, bytes, opts| { + photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()) })), ..Default::default() }, diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index 74fd29121a..62e9423f44 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -231,10 +231,16 @@ fn character_and_paragraph_dock_panels_share_the_headers_without_type_options() let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(names, [want], "tab {tab}"); } - // No type layer: the empty-state message, no sections. + // No type layer: the same dedicated sections edit Type tool defaults (#5556). select(&mut h, px); - assert!(headers(&h).is_empty()); - assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_some()); + for (tab, want) in [(0, "Character"), (1, "Paragraph")] { + set(&mut h, json!({"dockTabs": {"character": tab}})); + let hs = headers(&h); + assert!(hs.iter().all(|(_, r)| (r.height() - SECTION_H).abs() < 0.5), "{hs:?}"); + let names: Vec<&str> = hs.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, [want], "default tab {tab}"); + } + assert!(h.query_by_value("Select a type layer to edit its character and paragraph settings.").is_none()); } /// #2085: like Photoshop, Opacity and Fill stack in one right-aligned column: both fields end at diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index e233396cfd..3045ca1ca6 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -1,12 +1,13 @@ //! Edit › Puppet Warp on the canvas: click on the mesh to add pins, drag pins to move them, -//! ⌥-click a pin to remove it; the options bar sets mode, density, expansion, mesh visibility, +//! Delete/Backspace removes the selected pin; ⌥-click removes a pin under the pointer. +//! The options bar sets mode, density, expansion, mesh visibility, //! the selected pin's depth and rotation. ⌘Z / ⌘⇧Z undo and redo pin edits inside the session //! (#4440). ↩ / Commit runs `edit.puppetWarp` with the pins. //! //! The preview is the layer texture on the deformed triangle mesh (an egui textured mesh, drawn -//! by the GPU), re-solved with a few warm-started ARAP rounds per drag event (well under a -//! millisecond with the banded Cholesky factor). When a drag ends the mesh is solved from scratch -//! exactly like the engine, so what you see at rest is what Commit produces. +//! by the GPU). Each changed pin position uses the engine's iteration count and rest-mesh +//! initialization, reusing the cached solver factorization. Warm-starting from earlier drag +//! previews made the result depend on pointer history and snap to a different shape on release. use std::sync::Arc; @@ -79,6 +80,22 @@ impl PuppetSession { true } + /// Removes the selected constraint and its deformation as one undoable pin edit. + pub fn delete_selected_pin(&mut self) -> bool { + let Some(i) = self.selected.filter(|i| *i < self.warp.pins.len()) else { return false }; + // Finish an in-progress drag first, so undo can restore the pin, then undo its move. + if let Some(before) = self.drag_from.take() { + self.record(before); + } + let before = self.warp.pins.clone(); + self.warp.pins.remove(i); + self.selected = None; + self.drag = None; + self.rebind(); + self.record(before); + true + } + pub fn describe(&self) -> Value { json!({ "layer": self.layer.0, @@ -116,16 +133,15 @@ impl PuppetSession { let src: Vec<[f64; 2]> = self.warp.pins.iter().map(|p| p.src).collect(); let fixed: Vec = self.warp.pins.iter().map(|p| p.rotate.is_some()).collect(); self.solver = Some(PuppetSolver::new(self.mesh.clone(), &src, &fixed)); - self.solve(true); + self.solve(); } - /// Solves the deformation: exactly like the engine (`exact`), or a few warm rounds. - fn solve(&mut self, exact: bool) { + /// Solves from rest, exactly like the engine, during dragging as well as at rest. + fn solve(&mut self) { let Some(solver) = &self.solver else { return }; let dst: Vec<[f64; 2]> = self.warp.pins.iter().map(|p| p.dst).collect(); let rot: Vec> = self.warp.pins.iter().map(|p| p.rotate).collect(); - let warm = (!exact && self.deformed.len() == self.mesh.verts.len()).then_some(self.deformed.as_slice()); - let (v, _) = solver.solve(&dst, &rot, self.warp.mode, if exact { ITERATIONS } else { 6 }, warm); + let (v, _) = solver.solve(&dst, &rot, self.warp.mode, ITERATIONS, None); self.deformed = v; let src: Vec<[f64; 2]> = self.warp.pins.iter().map(|p| p.src).collect(); let depths: Vec = self.warp.pins.iter().map(|p| p.depth).collect(); @@ -242,6 +258,10 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { s.redo(); return Ok(s.describe()); } + if ui.get("deleteSelectedPin").and_then(Value::as_bool) == Some(true) { + s.delete_selected_pin(); + return Ok(s.describe()); + } let mut remesh = false; let (density, expansion) = (s.warp.density, s.warp.expansion); if let Some(m) = ui.get("mode").and_then(Value::as_str) { @@ -261,7 +281,7 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if remesh { s.remesh_or_restore(density, expansion)?; } else { - s.solve(true); + s.solve(); } Ok(s.describe()) } @@ -275,10 +295,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { let before = s.warp.pins.clone(); if let Some(i) = s.pin_at(p, tol) { if mods.alt { - s.warp.pins.remove(i); - s.selected = None; - s.rebind(); - s.record(before); + s.selected = Some(i); + s.delete_selected_pin(); } else { s.selected = Some(i); s.drag = Some(i); @@ -301,15 +319,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { && pin.dst != [x, y] { pin.dst = [x, y]; - s.solve(false); + s.solve(); } } ToolEvent::Up { x, y } => { if let Some(i) = s.drag.take() { - if let Some(pin) = s.warp.pins.get_mut(i) { + // A release usually repeats the last Move, but can carry a new position. + if let Some(pin) = s.warp.pins.get_mut(i) + && pin.dst != [x, y] + { pin.dst = [x, y]; + s.solve(); } - s.solve(true); if let Some(before) = s.drag_from.take() { s.record(before); } @@ -318,7 +339,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { } } -pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { +/// The warped pixels: image content, drawn under the grid and guides like the document so a +/// guide stays visible across the layer being warped (#7781). The mesh lines and pins are [`draw`]. +pub fn draw_content(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { let b = s.bounds; let (w, h) = (f64::from(b.width()), f64::from(b.height())); let mut mesh = egui::Mesh::with_texture(s.texture.id()); @@ -332,6 +355,10 @@ pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { mesh.add_triangle(a as u32, bb as u32, c as u32); } painter.add(mesh); +} + +/// The mesh lines and pins over the warped pixels ([`draw_content`]) and the guides. +pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { if s.show_mesh { let line = Stroke::new(0.6, Color32::from_rgba_unmultiplied(40, 40, 40, 150)); let mut seen = std::collections::HashSet::new(); @@ -436,7 +463,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if rebind { s.rebind(); } else if resolve { - s.solve(true); + s.solve(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::primary_button(ui, "✓", 32.0) @@ -454,6 +481,267 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + fn puppet_app(ctx: &egui::Context) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + begin(&mut app, ctx, "edit.puppetWarp").unwrap(); + app + } + + fn gesture(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) { + pointer(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, egui::Modifiers::NONE); + pointer(app, ToolEvent::Up { x: to[0], y: to[1] }, egui::Modifiers::NONE); + } + + fn press(app: &mut PhotocraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + let input = egui::RawInput { events, ..Default::default() }; + ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); + } + + #[test] + fn delete_keys_remove_selected_pin_and_restore_its_deformation_on_undo() { + for key in [egui::Key::Delete, egui::Key::Backspace] { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 40.0]); + gesture(&mut app, [75.0, 40.0], [75.0, 40.0]); + gesture(&mut app, [75.0, 40.0], [75.0, 30.0]); + gesture(&mut app, [25.0, 40.0], [25.0, 20.0]); + let s = app.distort.puppet.as_mut().unwrap(); + // Rotation/depth are part of a deleted pin's state, not just its position. + let before = s.warp.pins.clone(); + s.warp.pins[0].rotate = Some(15.0); + s.warp.pins[0].depth = 2; + s.rebind(); + s.record(before); + let pins = s.warp.pins.clone(); + let deformed = s.deformed.clone(); + let order = s.order.clone(); + let edits = s.undo.len(); + let survivor = &pins[1]; + let solver = PuppetSolver::new(s.mesh.clone(), &[survivor.src], &[survivor.rotate.is_some()]); + let expected = solver.solve(&[survivor.dst], &[survivor.rotate], s.warp.mode, ITERATIONS, None).0; + let past = app.session.active().unwrap().history.past_len(); + + press(&mut app, &ctx, key, egui::Modifiers::NONE); + let s = app.distort.puppet.as_ref().unwrap(); + assert_eq!(s.warp.pins, pins[1..], "{key:?}: delete the selected first pin, not the last"); + assert_eq!(s.selected, None); + assert_eq!(s.deformed, expected, "solve from rest without the deleted constraint"); + assert_eq!(s.order, solver.draw_order(&[survivor.depth], &[survivor.src])); + assert_eq!(s.undo.len(), edits + 1); + press(&mut app, &ctx, key, egui::Modifiers::NONE); + assert_eq!(app.distort.puppet.as_ref().unwrap().undo.len(), edits + 1, "no selection: no edit"); + + press(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND); + let s = app.distort.puppet.as_ref().unwrap(); + assert_eq!(s.warp.pins, pins); + assert_eq!(s.deformed, deformed); + assert_eq!(s.order, order); + press(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(app.distort.puppet.as_ref().unwrap().deformed, expected); + assert_eq!(app.session.active().unwrap().history.past_len(), past, "pin edits stay in the open session"); + + let original = app.session.active().unwrap().doc.clone(); + commit(&mut app); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1); + let committed = app.session.active().unwrap().doc.clone(); + assert_ne!(committed, original); + app.session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, original); + app.session.execute("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc, committed); + } + } + + #[test] + fn deleting_during_a_drag_settles_history_and_cannot_move_the_next_pin() { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 40.0]); + gesture(&mut app, [75.0, 40.0], [75.0, 40.0]); + let original = app.distort.puppet.as_ref().unwrap().warp.pins.clone(); + pointer(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Move { x: 25.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + let moved = app.distort.puppet.as_ref().unwrap().warp.pins.clone(); + press(&mut app, &ctx, egui::Key::Delete, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Move { x: 5.0, y: 5.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Up { x: 5.0, y: 5.0 }, egui::Modifiers::NONE); + let s = app.distort.puppet.as_mut().unwrap(); + assert_eq!(s.warp.pins, original[1..]); + assert!(s.drag.is_none() && s.drag_from.is_none()); + assert!(s.undo()); + assert_eq!(s.warp.pins, moved, "first undo restores the deleted pin at its moved position"); + assert!(s.undo()); + assert_eq!(s.warp.pins, original, "second undo reverts the preceding drag"); + assert!(s.redo()); + s.selected = Some(0); + control(&mut app, &json!({"deleteSelectedPin": true})).unwrap(); + assert!(!app.distort.puppet.as_mut().unwrap().redo(), "a new deletion discards the redo branch"); + } + + #[test] + fn deleting_last_pin_restores_rest_mesh_and_empty_commit_is_not_an_edit() { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 20.0]); + let past = app.session.active().unwrap().history.past_len(); + let s = app.distort.puppet.as_mut().unwrap(); + let pins = s.warp.pins.clone(); + s.selected = Some(usize::MAX); + press(&mut app, &ctx, egui::Key::Delete, egui::Modifiers::NONE); + assert_eq!(app.distort.puppet.as_ref().unwrap().warp.pins, pins, "stale selection is harmless"); + app.distort.puppet.as_mut().unwrap().selected = Some(0); + press(&mut app, &ctx, egui::Key::Backspace, egui::Modifiers::NONE); + let s = app.distort.puppet.as_ref().unwrap(); + assert!(s.warp.pins.is_empty()); + assert_eq!(s.deformed.len(), s.mesh.verts.len()); + for (deformed, rest) in s.deformed.iter().zip(&s.mesh.verts) { + assert!(dist(*deformed, *rest) < 1e-8, "no constraints: the mesh returns to rest"); + } + commit(&mut app); + assert!(app.distort.puppet.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + + #[test] + fn delete_keys_in_a_text_field_do_not_remove_pins() { + for key in [egui::Key::Delete, egui::Key::Backspace] { + let ctx = egui::Context::default(); + let mut app = puppet_app(&ctx); + gesture(&mut app, [25.0, 40.0], [25.0, 20.0]); + let mut text = "123".to_string(); + ctx.run_ui(Default::default(), |ui| { + ui.text_edit_singleline(&mut text).request_focus(); + }) + .textures_delta + .clear(); + let pins = app.distort.puppet.as_ref().unwrap().warp.pins.clone(); + let input = egui::RawInput { + events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.run_ui(input, |ui| { + assert!(ui.ctx().text_edit_focused()); + crate::shortcuts::handle(&mut app, ui.ctx()); + ui.text_edit_singleline(&mut text); + }) + .textures_delta + .clear(); + assert_eq!(app.distort.puppet.as_ref().unwrap().warp.pins, pins); + assert_eq!(app.distort.puppet.as_ref().unwrap().undo.len(), 1); + } + } + + fn long_puppet(ctx: &egui::Context, mode: PuppetMode, density: PuppetDensity) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 1000, "height": 300, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(40, 180, 960, 220), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + begin(&mut app, ctx, "edit.puppetWarp").unwrap(); + for x in [70.0, 160.0, 930.0] { + pointer(&mut app, ToolEvent::Down { x, y: 200.0, pressure: 1.0 }, egui::Modifiers::NONE); + pointer(&mut app, ToolEvent::Up { x, y: 200.0 }, egui::Modifiers::NONE); + } + let s = app.distort.puppet.as_mut().unwrap(); + s.warp.mode = mode; + s.warp.density = density; + s.rebuild_mesh(); + app + } + + fn assert_same_mesh(a: &[[f64; 2]], b: &[[f64; 2]]) { + assert_eq!(a.len(), b.len()); + let offset = a.iter().zip(b).map(|(a, b)| dist(*a, *b)).fold(0.0, f64::max); + assert!(offset < 1e-8, "mesh moved by {offset} px with unchanged pins"); + } + + #[test] + fn drag_preview_matches_release_history_and_committed_geometry() { + use photocraft_algo::puppet::{deform, render}; + for mode in [PuppetMode::Rigid, PuppetMode::Normal, PuppetMode::Distort] { + for density in [PuppetDensity::Fewer, PuppetDensity::Normal, PuppetDensity::More] { + let ctx = egui::Context::default(); + let mut app = long_puppet(&ctx, mode, density); + let rest = app.distort.puppet.as_ref().unwrap().deformed.clone(); + let past = app.session.active().unwrap().history.past_len(); + pointer(&mut app, ToolEvent::Down { x: 930.0, y: 200.0, pressure: 1.0 }, egui::Modifiers::NONE); + for step in 1..=12 { + let x = 930.0 - f64::from(step) * 20.0; + let y = 200.0 - f64::from(step) * 10.0; + pointer(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + } + let s = app.distort.puppet.as_ref().unwrap(); + let preview = s.deformed.clone(); + let order = s.order.clone(); + pointer(&mut app, ToolEvent::Up { x: 690.0, y: 80.0 }, egui::Modifiers::NONE); + let s = app.distort.puppet.as_mut().unwrap(); + assert_same_mesh(&preview, &s.deformed); + assert_eq!(order, s.order); + let (_, canonical, _) = deform(&s.surface, s.bounds, &s.warp, ITERATIONS); + assert_same_mesh(&preview, &canonical); + assert!(s.undo()); + assert_same_mesh(&rest, &s.deformed); + assert!(s.redo()); + assert_same_mesh(&preview, &s.deformed); + // Use the engine's interpolation to isolate geometry from GPU texture sampling. + let expected = render(&s.surface, s.bounds, &s.mesh, &preview, &order, photocraft_algo::transform::Interp::Bicubic); + let layer = s.layer; + assert_eq!(app.session.active().unwrap().history.past_len(), past); + commit(&mut app); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), past + 1); + assert_eq!(st.doc.layer(layer).unwrap().surface().unwrap(), &expected); + } + } + } + + #[test] + fn drag_preview_is_independent_of_pointer_path_and_honors_release_coordinates() { + let ctx = egui::Context::default(); + let mut app = long_puppet(&ctx, PuppetMode::Normal, PuppetDensity::More); + let s = app.distort.puppet.as_mut().unwrap(); + s.warp.pins[0].rotate = Some(25.0); + s.rebind(); + let pins = s.warp.pins.clone(); + let paths = [ + vec![[690.0, 80.0]], + vec![[870.0, 170.0], [810.0, 140.0], [750.0, 110.0], [690.0, 80.0]], + vec![[500.0, 30.0], [930.0, 200.0], [1000.0, 270.0], [690.0, 80.0]], + ]; + let mut expected = None; + for path in paths { + app.distort.puppet.as_mut().unwrap().restore(pins.clone()); + pointer(&mut app, ToolEvent::Down { x: 930.0, y: 200.0, pressure: 1.0 }, egui::Modifiers::NONE); + for [x, y] in path { + pointer(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + } + let preview = &app.distort.puppet.as_ref().unwrap().deformed; + assert_same_mesh(expected.get_or_insert_with(|| preview.clone()), preview); + // The last pointer-up may carry a new position without a preceding move event. + pointer(&mut app, ToolEvent::Up { x: 720.0, y: 90.0 }, egui::Modifiers::NONE); + let s = app.distort.puppet.as_ref().unwrap(); + assert_eq!(s.warp.pins[2].dst, [720.0, 90.0]); + let (_, canonical, _) = photocraft_algo::puppet::deform(&s.surface, s.bounds, &s.warp, ITERATIONS); + assert_same_mesh(&s.deformed, &canonical); + } + } + /// #5092: a Puppet Warp session left open does not follow the user to a new document. #[test] fn session_is_dropped_when_another_document_becomes_active() { diff --git a/crates/ui-egui/src/replace_prompt.rs b/crates/ui-egui/src/replace_prompt.rs new file mode 100644 index 0000000000..7f301db8e7 --- /dev/null +++ b/crates/ui-egui/src/replace_prompt.rs @@ -0,0 +1,46 @@ +//! A save asks before replacing a file that the save dialog did not ask about. The dialog asks +//! only about the name it answers, and the app can change that name afterwards: it gives a name +//! without an extension the suggested file's (#5039), and lowercases the extension when +//! Preferences ▸ File Handling › Lowercase Extension is on. When a file exists under the changed +//! name, Replace writes over it and Cancel ends the save like the save dialog's Cancel. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and +//! `ui.dialog.cancel` keeps it. The file dialog stays open until it is answered +//! ([`PhotocraftApp::file_dialog_open`]). + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::file_open::display_name; +use crate::state::DialogKind; + +const MARK: &str = "__replaceSaved"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Opens the prompt about `path`, the file a save writes to for `answered`, the save dialog's +/// answer. +pub(crate) fn open(app: &mut PhotocraftApp, answered: &str, path: &str) { + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", display_name(path).as_str())]); + let detail = crate::i18n::fmt(tl!("The name in the save dialog was “{name}”."), &[("name", display_name(answered).as_str())]); + let fields = json!({MARK: true, "__label": "Save", "message": message, "detail": detail, "path": path}); + app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); +} + +pub fn body(ui: &mut egui::Ui, fields: &Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); + ui.add_space(6.0); + ui.label(fields.get("detail").and_then(Value::as_str).unwrap_or_default()); +} + +/// Replace: the save the prompt held back. +pub fn confirm(app: &mut PhotocraftApp) -> Result { + app.answer_replace(true) +} + +/// Cancel: nothing is written. +pub fn cancel(app: &mut PhotocraftApp) -> Result { + app.answer_replace(false) +} diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 20efb9bc0b..495a1babf6 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -79,6 +79,38 @@ pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { }) } +/// `paint.patternStamp` params from the options bar and the Patterns panel (without `points` and +/// `target`): shared by the live preview and the commit. The jitter `seed` comes from the stroke's +/// first point, so the live stroke (which starts with only that point) and the commit agree. +pub(crate) fn pattern_stamp_params(app: &PhotocraftApp, points: &[[f64; 3]]) -> Value { + let o = &app.ui.tool_options; + let mut p = json!({ + "aligned": o.pattern_stamp_aligned, + "impressionist": o.pattern_stamp_impressionist, + "scale": o.pattern_stamp_scale, + "angle": o.pattern_stamp_angle, + "seed": stroke_seed(points), + }); + if o.pattern_stamp_aligned + && let Some(ph) = app.ui.pattern_stamp_phase + { + p["phase"] = json!(ph); + } + if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { + p["pattern"] = json!(id); + } + p +} + +/// The jitter seed of a stroke drawn live and committed on release: from its first position, which +/// the live stroke and the commit share (the engine's default hashes every point, which the live +/// stroke doesn't have yet). +pub(crate) fn stroke_seed(points: &[[f64; 3]]) -> u64 { + // Position only: a pen tap raises the first point's pressure on release (#1798). + let first = points.first().map(|p| json!([p[0], p[1]]).to_string()).unwrap_or_default(); + photocraft_engine::paint::rng::seed_from_bytes(first.as_bytes()) +} + /// Finish a stroke with a retouching tool. Returns false if `tool` isn't one. pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], mods: egui::Modifiers) -> bool { let o = app.ui.tool_options.clone(); @@ -94,23 +126,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m } Tool::MixerBrush => ("paint.mixerBrush", json!({})), Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)), - Tool::PatternStamp => { - let mut p = json!({ - "aligned": o.pattern_stamp_aligned, - "impressionist": o.pattern_stamp_impressionist, - "scale": o.pattern_stamp_scale, - "angle": o.pattern_stamp_angle, - }); - if o.pattern_stamp_aligned - && let Some(ph) = app.ui.pattern_stamp_phase - { - p["phase"] = json!(ph); - } - if let Some(id) = photocraft_engine::presets::patterns::current(&app.session).or(app.session.patterns.items.first()).map(|x| x.id.clone()) { - p["pattern"] = json!(id); - } - ("paint.patternStamp", p) - } + Tool::PatternStamp => ("paint.patternStamp", pattern_stamp_params(app, points)), Tool::Healing | Tool::CloneStamp => { let Some(p) = clone_params(app) else { // Option-click on the Mac, Alt-click on Windows. @@ -622,7 +638,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::value_field(ui, &mut o.cr_tolerance, 0.0..=100.0, "%", 62.0); crate::widgets::checkbox(ui, &mut o.cr_anti_alias, tl!("Anti-alias")); } - Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), + Tool::HistoryBrush => opt(ui, tl!("Paints from the History panel's source state")), Tool::QuickSelection => { crate::widgets::checkbox(ui, &mut o.sample_all.quick_selection, tl!("Sample All Layers")); crate::widgets::checkbox(ui, &mut o.enhance_edge, tl!("Enhance Edge")); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index a5f4bb0fd1..b304de83ca 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -1,6 +1,6 @@ //! View extras: rulers (⌘R), grid (⌘'), guides (⌘;). Guides are dragged out of the rulers and -//! moved with the Move tool (drag one off the canvas to delete it), all through undoable engine -//! commands (`view.newGuide` / `view.moveGuide` / `view.deleteGuide`). +//! moved with the Move tool (drag one off the canvas or onto a ruler to delete it), all through +//! undoable engine commands (`view.newGuide` / `view.moveGuide` / `view.deleteGuide`). use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::Document; @@ -64,19 +64,28 @@ fn unit_label(u: Unit) -> &'static str { /// Every length unit, in the Preferences › Units & Rulers order. const UNITS: [Unit; 7] = [Unit::Pixels, Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points, Unit::Picas, Unit::Percent]; -/// The ruler right-click menu: every length unit with its (translated) label and whether it's the -/// one in effect. Picking one changes the ruler unit exactly as the preference pane does. -fn unit_menu_items(current: Unit) -> Vec<(String, &'static str, bool)> { - UNITS.iter().map(|&u| (tl!(unit_label(u)).to_string(), u.name(), u == current)).collect() +/// The units Photoshop's Create Rectangle (and the other shape dialogs) offer on a right-click of +/// Width or Height: the lengths, without Percent. +pub(crate) const LENGTH_UNITS: [Unit; 6] = [Unit::Pixels, Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points, Unit::Picas]; + +/// The ruler right-click menu: every unit of `units` with its (translated) label and whether it's +/// the one in effect. Picking one changes the ruler unit exactly as the preference pane does. +fn unit_menu_items(current: Unit, units: &[Unit]) -> Vec<(String, &'static str, bool)> { + units.iter().map(|&u| (tl!(unit_label(u)).to_string(), u.name(), u == current)).collect() } /// The length units menu that right-clicking a ruler (or Free Transform's W and H fields) opens; /// the chosen unit's name is returned (`None` if the menu is dismissed without a pick). pub(crate) fn unit_menu(ui: &mut egui::Ui, current: Unit) -> Option<&'static str> { + unit_menu_of(ui, current, &UNITS) +} + +/// [`unit_menu`] with only `units` in it. +pub(crate) fn unit_menu_of(ui: &mut egui::Ui, current: Unit, units: &[Unit]) -> Option<&'static str> { crate::widgets::menu_scroll(ui, |ui| { ui.set_min_width(150.0); let mut chosen = None; - for (label, name, checked) in unit_menu_items(current) { + for (label, name, checked) in unit_menu_items(current, units) { let label = if checked { format!("✓ {label}") } else { label }; if ui.button(label).clicked() { ui.close(); @@ -96,13 +105,16 @@ pub struct GuideDrag { /// Where the pointer went down, on the guide's axis: released there, an existing guide is /// clicked (selected), not moved. pub press: f64, + /// Whether the pointer was over a ruler when the guide was let go (#6286): a guide released + /// there is removed, whatever its own axis reads. + pub over_ruler: bool, } impl GuideDrag { /// An existing guide taken at document point `p`. pub fn existing(vertical: bool, index: usize, p: [f64; 2]) -> Self { let at = if vertical { p[0] } else { p[1] }; - Self { vertical, index: Some(index), pos: at, press: at } + Self { vertical, index: Some(index), pos: at, press: at, over_ruler: false } } } @@ -111,6 +123,20 @@ pub fn content_rect(app: &PhotocraftApp, rect: Rect) -> Rect { if app.ui.extras.rulers { Rect::from_min_max(rect.min + vec2(RULER, RULER), rect.max) } else { rect } } +/// Whether the screen point `p` is over a ruler, inside the full canvas rect `full`. A guide let go +/// there is removed, as in Photoshop, and either ruler removes it (#6286): the top ruler measures +/// document x and the left one y, so the ruler the guide was not dragged out of can lie over a +/// coordinate that is still inside the document on the guide's own axis. +pub(crate) fn dropped_on_a_ruler(app: &PhotocraftApp, full: Rect, p: Pos2) -> bool { + if !app.ui.extras.rulers { + return false; + } + // The rulers take the strips [`content_rect`] trims off the canvas rect, and nothing else: a + // canvas point on the boundary is canvas. + let content = content_rect(app, full); + full.contains(p) && (p.x < content.min.x || p.y < content.min.y) +} + /// Tick step (document px) so major ticks are at least ~60 screen px apart: 1, 2, 5, 10, 20, 50… #[cfg(test)] fn tick_step(zoom: f32) -> f64 { @@ -213,6 +239,30 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, } } +/// The position readout of a guide being dragged: `X:` for a vertical guide, `Y:` for a horizontal +/// one, measured from the ruler zero point in the ruler unit. Pixels keep one decimal, as +/// Photoshop's "X: 103.2 px". +pub fn guide_drag_readout(app: &PhotocraftApp, d: GuideDrag) -> Option<(&'static str, String)> { + let doc = &app.session.active()?.doc; + let ur = &app.session.prefs().units_and_rulers; + let axis = usize::from(!d.vertical); + let px = d.pos - doc.ruler_origin.get(axis).copied().unwrap_or(0.0); + let extent = if d.vertical { doc.size.width } else { doc.size.height } as f64; + let value = match ur.rulers { + Unit::Pixels => photocraft_engine::prefs::fmt_decimals(px, 1), + _ => ur.format(px, f64::from(doc.resolution_dpi), extent), + }; + Some((if d.vertical { "X:" } else { "Y:" }, format!("{value} {}", ur.rulers.suffix()))) +} + +/// Draw [`guide_drag_readout`] beside the pointer while a guide is dragged. +pub fn draw_guide_drag_readout(app: &PhotocraftApp, ctx: &egui::Context) { + let (Some(d), Some(at)) = (app.guide_drag, ctx.pointer_latest_pos()) else { return }; + if let Some((label, value)) = guide_drag_readout(app, d) { + crate::canvas::draw_value_readout(app, ctx, "guide-drag-readout", at, [label], [value]); + } +} + /// Existing guide under a document point (within 4 screen px). pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { // Hidden canvas guides are not interactive: the Move tool must reach the layer below. @@ -235,12 +285,15 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { None } -/// Finish a guide drag: create, move, or delete (dropped outside the canvas). An existing guide -/// released where it was pressed is a click: it's selected and stays put, as in Photoshop; a -/// moved one stays selected. +/// Finish a guide drag: create, move, or delete (dropped outside the canvas or on a ruler). An +/// existing guide released where it was pressed is a click: it's selected and stays put, as in +/// Photoshop; a moved one stays selected. pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { let Some(size) = app.session.active().map(|s| s.doc.size) else { return }; + // A drop on a ruler removes the guide even when the drag held its own axis, which otherwise + // reads as a click: dragged sideways onto the left ruler, a horizontal guide never moves in y. if let Some(i) = d.index + && !d.over_ruler && (d.pos - d.press).abs() * f64::from(app.point_zoom().max(0.01)) < 0.5 { crate::guide_select::select(app, d.vertical, i); @@ -248,7 +301,7 @@ pub fn finish_drag(app: &mut PhotocraftApp, d: GuideDrag) { } let extent = if d.vertical { size.width } else { size.height } as f64; let orientation = if d.vertical { "vertical" } else { "horizontal" }; - let inside = d.pos >= 0.0 && d.pos <= extent; + let inside = !d.over_ruler && d.pos >= 0.0 && d.pos <= extent; let pos = (d.pos * 1000.0).round() / 1000.0; let _ = match (d.index, inside) { (None, true) => app.run("view.newGuide", json!({"orientation": orientation, "position": pos})), @@ -466,11 +519,13 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let d = xf.to_doc(pos); let along = if vertical { d[0] } else { d[1] }; let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; - app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos }); + app.guide_drag = Some(GuideDrag { vertical, index: None, pos, press: pos, over_ruler: false }); } if resp.drag_stopped() - && let Some(d) = app.guide_drag.take() + && let Some(mut d) = app.guide_drag.take() { + // Let go over a ruler (the other one, say) the drag is discarded, not placed (#6286). + d.over_ruler = resp.interact_pointer_pos().is_some_and(|p| dropped_on_a_ruler(app, full, p)); finish_drag(app, d); } resp.context_menu(|ui| { @@ -726,17 +781,134 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); app.hover_doc = None; - app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0 }); + app.guide_drag = Some(GuideDrag { vertical: true, index: None, pos: 42.0, press: 42.0, over_ruler: false }); let mut h = egui_kittest::Harness::builder() .with_size(vec2(260.0, 240.0)) .build_ui_state(|ui, app| crate::panels::dock_body(app, ui, crate::dock::Group::Navigator, 2), app); h.run_steps(2); assert!(h.query_by_label("42").is_some(), "vertical guide: X readout"); - h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0 }); + h.state_mut().guide_drag = Some(GuideDrag { vertical: false, index: None, pos: 17.0, press: 17.0, over_ruler: false }); h.run_steps(2); assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); } + /// A dragged guide shows its position beside the pointer: X for a vertical guide, Y for a + /// horizontal one, from the zero point, in the ruler unit (pixels to one decimal). + #[test] + fn guide_drag_shows_its_position_beside_the_pointer() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let v = GuideDrag { vertical: true, index: Some(0), pos: 103.24, press: 50.0, over_ruler: false }; + assert_eq!(guide_drag_readout(&app, v), Some(("X:", "103.2 px".to_owned()))); + let h = GuideDrag { vertical: false, index: None, pos: 40.0, press: 40.0, over_ruler: false }; + assert_eq!(guide_drag_readout(&app, h), Some(("Y:", "40 px".to_owned()))); + app.run("view.setRulerOrigin", json!({"x": 3.0, "y": 10.0})).unwrap(); + assert_eq!(guide_drag_readout(&app, h), Some(("Y:", "30 px".to_owned())), "from the zero point"); + app.run("view.resetRulerOrigin", json!({})).unwrap(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + assert_eq!(guide_drag_readout(&app, GuideDrag { vertical: true, index: None, pos: 103.0, press: 0.0, over_ruler: false }).map(|r| r.0), Some("X:")); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "pixels"})).unwrap(); + + app.guide_drag = Some(v); + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(|ui, app| draw_guide_drag_readout(app, ui.ctx()), app); + h.event(egui::Event::PointerMoved(pos2(50.0, 50.0))); + h.run_steps(2); + assert!(h.query_by_label("X:").is_some() && h.query_by_label("103.2 px").is_some()); + h.state_mut().guide_drag = None; + h.run_steps(2); + assert!(h.query_by_label("X:").is_none(), "no readout once the drag ends"); + } + + type AppHarness = egui_kittest::Harness<'static, PhotocraftApp>; + + /// The real canvas on a 400 × 300 document with a horizontal guide at y = 100, at 100 %, + /// with the Move tool and snapping off. + fn canvas_harness(rulers: bool) -> AppHarness { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 100})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = rulers; + app.ui.extras.snap = false; + app.ui.tool = crate::state::Tool::Move; + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (1.0, [200.0, 150.0], false); + h.run_steps(2); + h + } + + /// Screen point of document point `(x, y)` in [`canvas_harness`]. + fn canvas_at(h: &AppHarness, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let rect = content_rect(app, app.last_canvas_rect); + ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: 0.0, aspect: 1.0 }.to_screen(x, y) + } + + /// Moving a horizontal guide with the Move tool on the real canvas: `Y:` follows the pointer. + #[test] + fn moving_a_guide_on_the_canvas_shows_its_y() { + use egui_kittest::kittest::Queryable; + let mut h = canvas_harness(false); + let at = canvas_at; + let press = at(&h, 200.0, 100.0); + h.event(egui::Event::PointerMoved(press)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for y in [110.0, 125.0, 140.0] { + h.event(egui::Event::PointerMoved(at(&h, 210.0, y))); + h.run_steps(2); + } + assert!(h.state().guide_drag.is_some_and(|d| !d.vertical), "a horizontal guide is being dragged"); + assert!(h.query_by_label("Y:").is_some() && h.query_by_label("140 px").is_some(), "Y readout at the guide"); + assert!(h.query_by_label("X:").is_none()); + let up = at(&h, 210.0, 140.0); + h.event(egui::Event::PointerButton { pos: up, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.guides.horizontal, vec![140.0]); + assert!(h.query_by_label("Y:").is_none(), "gone after the drop"); + } + + /// A guide dragged out of the top ruler: in each frame the readout shows where the guide is + /// after that frame's pointer move (the ruler handles the drag after the canvas is drawn), and + /// the frame that drops it shows none. + #[test] + fn ruler_drag_readout_is_current_every_frame() { + use egui_kittest::kittest::Queryable; + let mut h = canvas_harness(true); + let from = pos2(canvas_at(&h, 200.0, 0.0).x, h.state().last_canvas_rect.top() + RULER / 2.0); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for y in [40.0, 60.0, 75.0] { + h.event(egui::Event::PointerMoved(canvas_at(&h, 200.0, y))); + h.run_steps(1); + let pos = h.state().guide_drag.map(|d| d.pos); + assert_eq!(pos, Some(f64::from(y)), "the drag follows the pointer"); + assert!(h.query_by_label(&format!("{y} px")).is_some(), "readout at {y} in the same frame"); + } + let up = canvas_at(&h, 200.0, 75.0); + h.event(egui::Event::PointerButton { pos: up, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().guide_drag.is_none()); + assert!(h.query_by_label("Y:").is_none(), "no readout in the frame that drops the guide"); + assert!(h.state().session.active().unwrap().doc.guides.horizontal.contains(&75.0), "the guide lands at y = 75"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; @@ -806,7 +978,7 @@ mod tests { #[test] fn unit_menu_lists_every_unit_with_the_current_one_checked() { - let items = unit_menu_items(Unit::Inches); + let items = unit_menu_items(Unit::Inches, &UNITS); assert_eq!(items.len(), Unit::NAMES.len()); // Every unit is offered, labelled, and in the preference's order. assert_eq!(items[0], ("Pixels".to_string(), "pixels", false)); @@ -861,14 +1033,14 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.session.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); app.sync_views(); - finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0 }); - finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0 }); // off canvas: ignored + finish_drag(&mut app, GuideDrag { vertical: true, index: None, pos: 50.0, press: 50.0, over_ruler: false }); + finish_drag(&mut app, GuideDrag { vertical: false, index: None, pos: 500.0, press: 500.0, over_ruler: false }); // off canvas: ignored assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![50.0]); assert!(app.session.active().unwrap().doc.guides.horizontal.is_empty()); assert_eq!(guide_at(&app, 51.0, 10.0), Some((true, 0))); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: 120.0, press: 50.0, over_ruler: false }); assert_eq!(app.session.active().unwrap().doc.guides.vertical, vec![120.0]); - finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0 }); + finish_drag(&mut app, GuideDrag { vertical: true, index: Some(0), pos: -30.0, press: 120.0, over_ruler: false }); assert!(app.session.active().unwrap().doc.guides.vertical.is_empty()); app.ui.extras.lock_guides = true; assert_eq!(guide_at(&app, 0.0, 0.0), None); @@ -1062,6 +1234,170 @@ mod tests { assert_eq!(drag(left, egui::Modifiers::ALT), (vec![], vec![90.0]), "Alt from the left ruler: horizontal"); } + /// #6286: a guide let go over a ruler is removed, whichever ruler it is. The top ruler measures + /// document x and the left one y, so a horizontal guide released on the left ruler is still + /// inside the document on its own axis, and used to be placed instead of discarded. + #[test] + fn a_guide_dropped_on_either_ruler_is_removed() { + let new_app = || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + app.ui.extras.rulers = true; + // No snapping, so a guide that is kept lands exactly where it is let go. + app.ui.extras.snap = false; + app + }; + let drag = |from: Pos2, to: Pos2| { + let (mut h, _, _) = ruler_harness(new_app()); + h.event(egui::Event::PointerMoved(from)); + press(&mut h, from, true, egui::Modifiers::NONE); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(1); + press(&mut h, to, false, egui::Modifiers::NONE); + let g = &h.state().session.active().unwrap().doc.guides; + (g.vertical.clone(), g.horizontal.clone()) + }; + // #5698: out of the top ruler is a horizontal guide, out of the left one a vertical guide. + let (top, left) = (pos2(200.0, RULER / 2.0), pos2(RULER / 2.0, 150.0)); + assert_eq!(drag(top, left), (vec![], vec![]), "out of the top ruler, released on the left ruler"); + assert_eq!(drag(left, top), (vec![], vec![]), "out of the left ruler, released on the top ruler"); + // Released on the canvas the guide lands where it was let go, as before. + assert_eq!(drag(top, pos2(200.0, 150.0)), (vec![], vec![56.0]), "released on the canvas"); + assert_eq!(drag(left, pos2(120.0, 150.0)), (vec![40.0], vec![]), "released on the canvas"); + } + + /// #6286: the guide is removed when it is let go over a ruler, and moved when it is not. The + /// release can be inside the document on the guide's own axis and still be a ruler drop, so the + /// two are told apart by where the pointer is, not by the position it reads. + #[test] + fn a_guide_let_go_on_a_ruler_is_removed_not_moved() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.sync_views(); + let horizontal = |app: &PhotocraftApp| app.session.active().unwrap().doc.guides.horizontal.clone(); + for drag in [ + GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 10.0, over_ruler: true }, + // Dragged sideways onto the left ruler, a horizontal guide holds its y, which on its own + // reads as a click: the ruler still removes it. + GuideDrag { vertical: false, index: Some(0), pos: 50.0, press: 50.0, over_ruler: true }, + ] { + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); + finish_drag(&mut app, drag); + assert!(horizontal(&app).is_empty(), "let go on a ruler: removed, {drag:?}"); + } + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 50})).unwrap(); + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 10.0, over_ruler: false }); + assert_eq!(horizontal(&app), vec![80.0], "let go on the canvas: moved"); + // Clicked where it was pressed, on the canvas: selected and kept, as before. + finish_drag(&mut app, GuideDrag { vertical: false, index: Some(0), pos: 80.0, press: 80.0, over_ruler: false }); + assert_eq!(horizontal(&app), vec![80.0], "clicked on the canvas: kept"); + assert!(app.ui.selected_guide.is_some(), "clicked on the canvas: selected"); + } + + /// The ruler bands are the strips the rulers take off the canvas rect, and only while the rulers + /// are shown: with them hidden the whole canvas rect is canvas (#6286). + #[test] + fn only_the_ruler_bands_count_as_a_ruler() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let full = Rect::from_min_size(pos2(16.0, 42.0), vec2(968.0, 642.0)); + let content = Rect::from_min_size(pos2(32.0, 58.0), vec2(952.0, 626.0)); + app.ui.extras.rulers = true; + assert!(dropped_on_a_ruler(&app, full, pos2(full.min.x + RULER / 2.0, 371.0)), "the left ruler"); + assert!(dropped_on_a_ruler(&app, full, pos2(508.0, full.min.y + RULER / 2.0)), "the top ruler"); + assert!(dropped_on_a_ruler(&app, full, full.min), "the ruler corner"); + assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, 371.0)), "the canvas"); + assert!(!dropped_on_a_ruler(&app, full, content.min), "the canvas' own top-left corner"); + assert!(!dropped_on_a_ruler(&app, full, pos2(content.min.x + 0.5, 371.0)), "the first canvas column"); + assert!(!dropped_on_a_ruler(&app, full, pos2(508.0, content.min.y + 0.5)), "the first canvas row"); + assert!(!dropped_on_a_ruler(&app, full, pos2(full.min.x - 1.0, 371.0)), "outside the canvas rect"); + app.ui.extras.rulers = false; + for p in [pos2(full.min.x + RULER / 2.0, 371.0), pos2(508.0, full.min.y + RULER / 2.0), full.min] { + assert!(!dropped_on_a_ruler(&app, full, p), "rulers hidden: no band at {p:?}"); + } + } + + /// The canvas the guides live on, rulers on, at 100 %. + fn tool_canvas_harness(tool: crate::state::Tool) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = true; + app.ui.extras.snap = false; + app.ui.tool = tool; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h + } + + /// #6286: an existing guide dragged onto a ruler with the Move tool is deleted, though the + /// release is inside the document on the guide's own axis: the left ruler measures y, so a + /// horizontal guide let go there still reads a y inside the canvas and used to be moved. Held + /// level, that release reads as a click on the guide, which a ruler drop also outranks. + #[test] + fn a_guide_dragged_onto_a_ruler_is_deleted_through_the_canvas() { + for below in [50.0f32, 0.0] { + let mut h = tool_canvas_harness(crate::state::Tool::Move); + h.state_mut().run("view.newGuide", json!({"orientation": "horizontal", "position": 150})).unwrap(); + h.run_steps(1); + let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) + }; + let on_the_guide = at(&h, 200.0, 150.0); + // In the left ruler's band, `below` document px under the press: inside the canvas on the + // guide's own axis, which is where the old code moved the guide instead of removing it. + let canvas = h.state().last_canvas_rect; + let onto_the_left_ruler = pos2(canvas.min.x + RULER / 2.0, on_the_guide.y + below); + // Along the guide, so a held-level release keeps its y, the way a sideways drag does. + let dragged = pos2(on_the_guide.x - 12.0, on_the_guide.y); + h.event(egui::Event::PointerMoved(on_the_guide)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: on_the_guide, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Past egui's click distance, so the drag starts at the press, on the guide. + h.event(egui::Event::PointerMoved(dragged)); + h.run_steps(2); + assert!(h.state().guide_drag.is_some(), "the Move tool takes the guide"); + h.event(egui::Event::PointerMoved(onto_the_left_ruler)); + h.run_steps(1); + h.event(egui::Event::PointerButton { + pos: onto_the_left_ruler, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + let g = &h.state().session.active().unwrap().doc.guides; + assert!(g.horizontal.is_empty(), "{below} px below the press, released on the left ruler: got {:?}", g.horizontal); + assert!(h.state().guide_drag.is_none(), "the drag is over"); + } + } + /// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws /// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped. #[test] diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 3396dfcdd5..97f252772f 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -9,8 +9,8 @@ fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new( Session::new(), Services { - import: Some(Box::new(|name, bytes, max_svg_group_depth| { - 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, bytes, opts| { + 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, path, _| { photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) @@ -30,6 +30,16 @@ fn identity(app: &PhotocraftApp) -> (String, Option, u64, u64) { (st.doc.name.clone(), st.path.clone(), st.revision, st.saved_revision) } +#[test] +fn save_settings_share_only_the_open_documents_native_cache() { + let mut app = app(); + let first = app.save_settings().io_options().pcraft_cache.unwrap(); + assert_eq!(first, app.session.active().unwrap().pcraft_cache); + assert_eq!(first, app.save_settings().io_options().pcraft_cache.unwrap()); + app.run("file.new", json!({"width": 2, "height": 2})).unwrap(); + assert_ne!(first, app.save_settings().io_options().pcraft_cache.unwrap()); +} + #[test] fn new_document_psd_and_subsequent_save_as_adopt_the_written_file_name() { for depth in [8, 16, 32] { @@ -139,7 +149,8 @@ fn save_as_flat_keeps_the_layered_original() { app.run("edit.fill", json!({"color": "#000000"})).unwrap(); let before = identity(&app); for flat in ["art.jpg", "art.png"] { - menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": flat})).unwrap(); + // A JPEG Options answer, so the JPEG doesn't wait on the prompt. + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": flat, "jpegFormat": "baseline"})).unwrap(); assert_eq!(identity(&app), before, "{flat}, layered: {layered}"); } menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); @@ -149,8 +160,15 @@ fn save_as_flat_keeps_the_layered_original() { } // An untitled layered document saved as a JPEG is still untitled and unsaved. let mut app = app(); + let written = Rc::new(RefCell::new(Vec::new())); + let w = written.clone(); + app.services.write = Some(Box::new(move |path, _| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })); let before = identity(&app); - app.save_as(Some("flat.jpg".into())).unwrap(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "flat.jpg", "jpegFormat": "baseline"})).unwrap(); + assert_eq!(*written.borrow(), ["flat.jpg"]); assert_eq!(identity(&app), before); } @@ -162,6 +180,8 @@ fn save_as_flat_retargets_a_flat_document() { app.save_as(Some("shot.png".into())).unwrap(); assert_eq!(app.session.active().unwrap().path.as_deref(), Some("shot.png")); app.save_as(Some("shot.jpg".into())).unwrap(); + // JPEG Options asks first; OK writes. + crate::jpeg_options_ui::confirm(&mut app, &egui::Context::default()); let st = app.session.active().unwrap(); assert_eq!((st.doc.name.as_str(), st.path.as_deref()), ("shot.jpg", Some("shot.jpg"))); assert_eq!(st.saved_revision, st.revision); diff --git a/crates/ui-egui/src/screen_picker.rs b/crates/ui-egui/src/screen_picker.rs index 944af8bff6..f0c12e96cf 100644 --- a/crates/ui-egui/src/screen_picker.rs +++ b/crates/ui-egui/src/screen_picker.rs @@ -247,7 +247,15 @@ pub fn busy(ctx: &Context) -> bool { } pub fn cancel(ctx: &Context) { let r = runtime(ctx); - lock(&r).answer = Some(Ok(None)); + let mut state = lock(&r); + if state.owner.is_some() { + state.answer = Some(Ok(None)); + } else { + // A request can be queued by the button before tick starts the capture service. + // Dropping that request cancels it without leaving an answer for the next pick. + state.request = None; + } + drop(state); ctx.request_repaint(); } @@ -534,6 +542,31 @@ mod tests { assert!(tick(app, ctx)); (owner, tx, cancelled) } + #[test] + fn cancel_while_queued_does_not_start_or_poison_the_next_pick() { + let ctx = Context::default(); + let mut app = app(); + let launched = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = launched.clone(); + app.services.screen_pick = Some(Box::new(move |_| { + started.store(true, std::sync::atomic::Ordering::Relaxed); + let (_tx, rx) = std::sync::mpsc::channel(); + Pending { receiver: rx, cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)) } + })); + let owner = Id::new("queued"); + lock(&runtime(&ctx)).request = Some(owner); + cancel(&ctx); + assert!(!tick(&mut app, &ctx)); + assert!(!launched.load(std::sync::atomic::Ordering::Relaxed)); + assert_eq!(take(&ctx, owner), None); + // An idle Cancel must not be retained as the reply to a future request. + cancel(&ctx); + let (owner, tx, _) = queued(&mut app, &ctx); + tx.send(Ok(Capture::Color(Some([0.2, 0.4, 0.6])))).unwrap(); + assert!(!tick(&mut app, &ctx)); + assert_eq!(take(&ctx, owner), Some([0.2, 0.4, 0.6])); + } + #[test] fn cancellation_wins_over_a_simultaneously_completed_pick() { let mut app = app(); diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index e0867436c4..375edc13b6 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -315,7 +315,18 @@ mod tests { assert_eq!(clamp_axis(-400.0, 1000.0, 500.0, 0.2), -250.0, "the image's right edge on the view's"); assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's"); assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred"); - let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800], rotation: 0.0 }; + let mut v = View { + zoom: 1.0, + center: [-500.0, 20.0], + fit_pending: false, + fill_pending: false, + doc_size: [1000, 800], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; assert!(clamp_view(&mut v, vec2(500.0, 400.0), 1.0)); assert_eq!(v.center, [250.0, 200.0]); assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop"); @@ -334,7 +345,18 @@ mod tests { let (at, len) = s.thumb((0.0, 1000.0), 0.0); assert!(at.is_finite() && len.is_finite()); assert_eq!(s.doc_per_point((0.0, 1000.0), 0.0), 0.0); - let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, fill_pending: false, doc_size: [0, 0], rotation: 0.0 }; + let mut v = View { + zoom: 1.0, + center: [0.0, 0.0], + fit_pending: false, + fill_pending: false, + doc_size: [0, 0], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + }; assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0)); } diff --git a/crates/ui-egui/src/select_and_mask_button_tests.rs b/crates/ui-egui/src/select_and_mask_button_tests.rs new file mode 100644 index 0000000000..5426b778ac --- /dev/null +++ b/crates/ui-egui/src/select_and_mask_button_tests.rs @@ -0,0 +1,46 @@ +//! #7520: the options bar's Select and Mask… button is greyed out while nothing is selected. +use crate::PhotocraftApp; +use crate::state::Tool; +use egui::vec2; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use serde_json::json; + +fn harness(tool: Tool, select: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + if select { + app.run("select.all", json!({})).unwrap(); + } + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + crate::panels::options_bar(app, ui); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); + h.run_steps(4); + h +} + +#[test] +fn select_and_mask_is_disabled_without_a_selection() { + for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] { + let h = harness(tool, false); + assert!(h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be greyed out with no selection"); + } +} + +#[test] +fn select_and_mask_is_enabled_with_a_selection() { + for tool in [Tool::RectMarquee, Tool::Lasso, Tool::MagicWand] { + let h = harness(tool, true); + assert!(!h.get_by_label("Select and Mask…").accesskit_node().is_disabled(), "{tool:?}: button should be live with a selection"); + } +} diff --git a/crates/ui-egui/src/shape_dialog.rs b/crates/ui-egui/src/shape_dialog.rs index f643c210cd..ca68e87015 100644 --- a/crates/ui-egui/src/shape_dialog.rs +++ b/crates/ui-egui/src/shape_dialog.rs @@ -2,11 +2,14 @@ //! opens Photoshop's creation dialog instead of drawing. OK runs the same `shape.create` command as //! a drag, with the options bar's fill and stroke, at the clicked point (or centred on it). //! Width and height are stored in document pixels (so automation can set them with -//! `ui.dialog.set`) and shown in the ruler unit. +//! `ui.dialog.set`) and shown in the ruler unit, or each in one picked from its right-click menu +//! (`widthUnit`, `heightUnit`; not remembered, as in Photoshop). use egui::{Align2, Sense, pos2, vec2}; use serde_json::{Map, Value, json}; +use photocraft_engine::prefs::Unit; + use crate::PhotocraftApp; use crate::state::{DialogKind, Tool}; use crate::theme::Tokens; @@ -160,24 +163,33 @@ fn label(ui: &mut egui::Ui, text: &str) { pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { ui.spacing_mut().item_spacing.y = 6.0; let kind = f.get(MARK).and_then(Value::as_str).unwrap_or("").to_string(); - // Width and height in the ruler unit (percent of the document's side). + // Width and height each in its own unit (percent of the document's side): the ruler unit, or + // the one picked from that field's right-click menu, as in Photoshop (#7786). let ur = &app.session.prefs().units_and_rulers; - let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); + let (rulers, ppi) = (ur.rulers, ur.point_size.per_inch()); let (dpi, size) = app .session .active() .map_or((72.0, [1.0, 1.0]), |d| (f64::from(d.doc.resolution_dpi.max(1.0)), [f64::from(d.doc.size.width), f64::from(d.doc.size.height)])); - for (key, text, extent) in [("width", tl!("Width:"), size[0]), ("height", tl!("Height:"), size[1])] { + for (key, unit_key, text, extent) in [("width", "widthUnit", tl!("Width:"), size[0]), ("height", "heightUnit", tl!("Height:"), size[1])] { + let unit = f.get(unit_key).and_then(Value::as_str).and_then(Unit::parse).unwrap_or(rulers); ui.horizontal(|ui| { label(ui, text); let px = num(f, key).unwrap_or(100.0); let to_unit = |px: f64| unit.from_px(px, dpi, extent, ppi) as f32; let mut v = to_unit(px); let places = unit.decimals() as u32; - if crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places).changed() { + let resp = crate::widgets::value_field_prec(ui, &mut v, to_unit(1.0)..=to_unit(MAX_PX), unit.suffix(), 110.0, places); + if resp.changed() { let px = unit.to_px(f64::from(v), dpi, extent, ppi).clamp(1.0, MAX_PX); f.insert(key.into(), json!((px * 1000.0).round() / 1000.0)); } + // Right-click: the length units for this field (the sizes stay in pixels). + resp.context_menu(|ui| { + if let Some(name) = crate::rulers::unit_menu_of(ui, unit, &crate::rulers::LENGTH_UNITS) { + f.insert(unit_key.into(), json!(name)); + } + }); }); } match kind.as_str() { diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index a0b5c61596..d9e61c8830 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -76,7 +76,7 @@ fn from_center_and_the_other_tools() { let id = only_dialog(&app); set(&mut app, id, json!({"width": 50, "height": 60, "sides": 7, "starRatio": 50})); crate::dialogs::confirm(&mut app, id).unwrap(); - assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5 }); + assert_eq!(live(&app), LiveShape::Polygon { rect: [10.0, 10.0, 50.0, 60.0], sides: 7, star_ratio: 0.5, corner_radius: 0.0 }); press(&mut app, Tool::Triangle, [5.0, 6.0], [5.0, 6.0]); let id = only_dialog(&app); @@ -248,3 +248,85 @@ fn the_dialog_body_renders_for_every_tool_and_unit() { app.ui.close_dialog(id); } } + +/// One frame of a dialog body with `events`: the texts it draws, each with its screen rect. +fn frame(ctx: &egui::Context, app: &PhotocraftApp, fields: &mut serde_json::Map, events: Vec) -> Vec<(String, egui::Rect)> { + let input = egui::RawInput { events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| body(app, ui, fields)); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for clip in &out.shapes { + if let egui::Shape::Text(t) = &clip.shape { + texts.push((t.galley.text().to_string(), t.galley.rect.translate(t.pos.to_vec2()))); + } + } + texts +} + +/// A click of `button` at `pos`, as frames: move there, press, release. +fn click(ctx: &egui::Context, app: &PhotocraftApp, f: &mut serde_json::Map, pos: egui::Pos2, button: egui::PointerButton) { + let press = |pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: egui::Modifiers::NONE }; + frame(ctx, app, f, vec![egui::Event::PointerMoved(pos)]); + frame(ctx, app, f, vec![press(true)]); + frame(ctx, app, f, vec![press(false)]); +} + +/// Each of Width and Height: its value and unit, and the value's screen rect. +fn sides(texts: &[(String, egui::Rect)]) -> Vec<(String, String, egui::Rect)> { + ["Width:", "Height:"] + .iter() + .filter_map(|l| texts.iter().position(|(t, _)| t == l)) + .filter_map(|i| { + let (v, r) = texts.get(i + 1)?.clone(); + Some((v, texts.get(i + 2)?.0.clone(), r)) + }) + .collect() +} + +/// #7786: right-clicking Width or Height offers the length units (no Percent, as in Photoshop's +/// Create Rectangle) and switches only that field; the sizes stay in pixels and the unit isn't +/// remembered, so the next dialog follows the ruler unit again. +#[test] +fn right_click_picks_the_unit_of_that_field_only_and_it_is_not_remembered() { + let mut app = app(); + press(&mut app, Tool::Rectangle, [10.0, 10.0], [10.0, 10.0]); + let id = only_dialog(&app); + let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); + let ctx = egui::Context::default(); + let shown = |s: &[(String, String, egui::Rect)]| s.iter().map(|(v, u, _)| (v.clone(), u.clone())).collect::>(); + let px = ("100".to_string(), "px".to_string()); + let texts = frame(&ctx, &app, &mut f, vec![]); + let s = sides(&texts); + assert_eq!(shown(&s), [px.clone(), px.clone()], "shown in the ruler unit, pixels"); + // Right-click Width: its menu lists the lengths and no Percent. + click(&ctx, &app, &mut f, s[0].2.center(), egui::PointerButton::Secondary); + let menu = frame(&ctx, &app, &mut f, vec![]); + let has = |label: &str| menu.iter().any(|(t, _)| t.ends_with(label)); + assert!(["Pixels", "Inches", "Centimeters", "Millimeters", "Points", "Picas"].iter().all(|l| has(l)), "{menu:?}"); + assert!(!has("Percent"), "Photoshop's menu here has no Percent"); + let mm_item = menu.iter().find(|(t, _)| t == "Millimeters").unwrap().1.center(); + click(&ctx, &app, &mut f, mm_item, egui::PointerButton::Primary); + // Only Width changes: 100 px at the document's 72 ppi is 35.28 mm; the size stays in pixels. + let s = sides(&frame(&ctx, &app, &mut f, vec![])); + assert_eq!(shown(&s), [("35.28".to_string(), "mm".to_string()), px.clone()]); + assert_eq!((f["widthUnit"].as_str(), f.get("heightUnit")), (Some("mm"), None)); + assert_eq!(f["width"].as_f64(), Some(100.0), "the size stays in pixels"); + // Height has its own menu, with its own unit checked: Inches switches Height alone. + click(&ctx, &app, &mut f, s[1].2.center(), egui::PointerButton::Secondary); + let menu = frame(&ctx, &app, &mut f, vec![]); + assert!(menu.iter().any(|(t, _)| t == "✓ Pixels"), "{menu:?}"); + let in_item = menu.iter().find(|(t, _)| t == "Inches").unwrap().1.center(); + click(&ctx, &app, &mut f, in_item, egui::PointerButton::Primary); + let s = sides(&frame(&ctx, &app, &mut f, vec![])); + assert_eq!(shown(&s), [("35.28".to_string(), "mm".to_string()), ("1.389".to_string(), "in".to_string())]); + // OK, then the rulers change: the next dialog shows the new ruler unit, not the pick. + set(&mut app, id, json!({"widthUnit": "mm", "width": 50})); + crate::dialogs::confirm(&mut app, id).unwrap(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "cm"})).unwrap(); + press(&mut app, Tool::Rectangle, [40.0, 40.0], [40.0, 40.0]); + let id = only_dialog(&app); + let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); + assert_eq!((f.get("widthUnit"), f["width"].as_f64()), (None, Some(50.0)), "the size is remembered, the unit isn't"); + let s = sides(&frame(&egui::Context::default(), &app, &mut f, vec![])); + assert_eq!(shown(&s), [("1.76".to_string(), "cm".to_string()), ("3.53".to_string(), "cm".to_string())]); +} diff --git a/crates/ui-egui/src/shape_fill_ui.rs b/crates/ui-egui/src/shape_fill_ui.rs index 37731ce243..20d297d725 100644 --- a/crates/ui-egui/src/shape_fill_ui.rs +++ b/crates/ui-egui/src/shape_fill_ui.rs @@ -82,6 +82,41 @@ pub(crate) fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result app.run(COMMAND, json!({"layer": layer.0, "fill": [color.c[0], color.c[1], color.c[2], color.alpha]})) } +/// A new foreground colour from the Color panel, a swatch or the Color Picker fills the selected +/// shape layer, keeping the fill's own opacity, as in Photoshop (#5568). Edits sharing `coalesce` +/// (one Color panel drag) are one undo step. Locked shapes and other layers are left alone. +pub(crate) fn foreground_changed(app: &mut PhotocraftApp, coalesce: Option) { + let Some(st) = app.session.active() else { return }; + let Some(layer) = st.active_layer else { return }; + let Some(LayerContent::Shape(sh)) = st.doc.layer(layer).map(|l| &l.content) else { return }; + let locks = st.doc.effective_locks(layer); + if locks.all || locks.pixels { + return; + } + let fg = app.session.tools.foreground; + let alpha = match &sh.fill { + Some(Fill::Solid(c)) => c.alpha, + _ => 1.0, + }; + if sh.fill == Some(Fill::Solid(Color::rgba(fg[0], fg[1], fg[2], alpha))) { + return; + } + let mut p = json!({"layer": layer.0, "fill": [fg[0], fg[1], fg[2], alpha]}); + if let Some(key) = coalesce { + p["coalesce"] = json!(key); + } + if let Err(e) = app.run(COMMAND, p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// The coalesce key of the pointer gesture under way (its press time), so one Color panel drag +/// is one undo step; `None` for clicks and typed values. +pub(crate) fn gesture(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.press_start_time()).map(|t| format!("color-panel-fill:{}", t.to_bits())) +} + /// The canvas preview while the picker is open: the document with the shape re-rendered in the /// picked colour, cached per dialog, revision and colour. pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { diff --git a/crates/ui-egui/src/shape_stroke_ui.rs b/crates/ui-egui/src/shape_stroke_ui.rs index a44e321dfc..e568403c43 100644 --- a/crates/ui-egui/src/shape_stroke_ui.rs +++ b/crates/ui-egui/src/shape_stroke_ui.rs @@ -74,7 +74,7 @@ impl StrokeOptions { }; } - fn valid(&self) -> bool { + pub(crate) fn valid(&self) -> bool { self.stroke(3.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)).validate().is_ok() } } diff --git a/crates/ui-egui/src/shortcut_capture_tests.rs b/crates/ui-egui/src/shortcut_capture_tests.rs index f5db001707..c7853067aa 100644 --- a/crates/ui-egui/src/shortcut_capture_tests.rs +++ b/crates/ui-egui/src/shortcut_capture_tests.rs @@ -13,9 +13,25 @@ fn key(h: &mut Harness<'_, PhotocraftApp>, key: Key, pressed: bool, modifiers: M h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); } +fn input_state(events: Vec, modifiers: Modifiers) -> egui::InputState { + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + let state = ctx.input(|i| i.clone()); + ctx.end_pass().textures_delta.clear(); + let mut state = state; + state.modifiers = modifiers; + state +} + /// The Keyboard Shortcuts dialog with `command` selected, filtered to it. fn dialog(ppp: f32, command: &str, filter: &str) -> (Harness<'static, PhotocraftApp>, u64) { - let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_pixels_per_point(ppp).with_max_steps(64).build_eframe(|cc| { + dialog_with_renderer(ppp, command, filter, false) +} + +fn dialog_with_renderer(ppp: f32, command: &str, filter: &str, gpu: bool) -> (Harness<'static, PhotocraftApp>, u64) { + let builder = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_pixels_per_point(ppp).with_max_steps(64); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()) }); @@ -134,21 +150,78 @@ fn capture(h: &mut Harness<'_, PhotocraftApp>, button: &str, modifier: Key, held h.run_steps(2); } +fn captured_ctrl_k(ppp: f32, gpu: bool) -> Harness<'static, PhotocraftApp> { + let (mut h, id) = dialog_with_renderer(ppp, "edit.search", "search…", gpu); + // Search's default is ⌘F (Photoshop); ⌘K is Preferences, so this also reports a conflict. + let current = crate::shortcuts::pretty("Cmd+F"); + // On Windows and Linux the Ctrl key is the command modifier. + capture(&mut h, ¤t, Key::ControlLeft, Modifiers::COMMAND, Key::K); + assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Cmd+K"), "@{ppp}x"); + assert_eq!(field(&h, id, "capture"), json!(false)); + let message = field(&h, id, "message"); + assert!(!message.as_str().unwrap().contains("ontrol"), "@{ppp}x: {message}"); + // The row shows the new shortcut. + h.get_by_label(&crate::shortcuts::pretty("Cmd+K")); + h +} + +#[test] +fn capture_key_press_records_shift_when_key_events_carry_no_modifier_bits() { + // Same event pattern as Linux/Windows egui: modifier and letter Key events with empty bits. + // Legacy capture → (F6, NONE) → shortcut_text → "F6"; capture_key_press → "Shift+F6". + let key_event = |key: Key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let (mods, pressed) = + crate::prefs_ui::capture_key_press(Modifiers::NONE, &input_state(vec![key_event(Key::ShiftLeft), key_event(Key::F6)], Modifiers::NONE)); + assert!(mods.shift); + let (key, m) = pressed.expect("F6 press"); + assert_eq!(key, Key::F6); + assert!(m.shift); + assert_eq!(crate::prefs_ui::shortcut_text(key, m).as_deref(), Some("Shift+F6")); + let (mods, pressed) = crate::prefs_ui::capture_key_press(Modifiers::NONE, &input_state(vec![key_event(Key::ShiftLeft)], Modifiers::NONE)); + assert!(mods.shift); + assert!(pressed.is_none(), "modifier alone does not finish capture"); + let (_, pressed) = crate::prefs_ui::capture_key_press(mods, &input_state(vec![key_event(Key::F6)], Modifiers::NONE)); + let (_, m) = pressed.expect("letter in a later frame"); + assert!(m.shift, "captureMods carries Shift across frames"); + assert_eq!(crate::prefs_ui::shortcut_text(Key::F6, m).as_deref(), Some("Shift+F6")); +} + +#[test] +fn shift_is_recorded_when_modifier_key_events_carry_no_bits() { + // End-to-end: dialog capture → overrides JSON (shortcut_text → prefs::normalize_shortcut). + let (mut h, id) = dialog(1.0, "edit.search", "search…"); + h.get_by_label(&crate::shortcuts::pretty("Cmd+F")).click(); + h.run_steps(2); + key(&mut h, Key::ShiftLeft, true, Modifiers::NONE); + h.run_steps(1); + key(&mut h, Key::F6, true, Modifiers::NONE); + h.run_steps(2); + assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Shift+F6")); + assert_eq!(field(&h, id, "capture"), json!(false)); +} + #[test] fn ctrl_k_is_recorded_as_ctrl_k_not_the_control_key() { for ppp in [1.0, 2.0] { - let (mut h, id) = dialog(ppp, "edit.search", "search…"); - // Search's default is ⌘F (Photoshop); ⌘K is Preferences, so this also reports a conflict. - let current = crate::shortcuts::pretty("Cmd+F"); - // On Windows and Linux the Ctrl key is the command modifier. - capture(&mut h, ¤t, Key::ControlLeft, Modifiers::COMMAND, Key::K); - assert_eq!(field(&h, id, "overrides")["edit.search"], json!("Cmd+K"), "@{ppp}x"); - assert_eq!(field(&h, id, "capture"), json!(false)); - let message = field(&h, id, "message"); - assert!(!message.as_str().unwrap().contains("ontrol"), "@{ppp}x: {message}"); - // The row shows the new shortcut. - h.get_by_label(&crate::shortcuts::pretty("Cmd+K")); - h.render().ok(); + let _h = captured_ctrl_k(ppp, false); + } +} + +/// Opt-in visual check, following the Camera Raw localization snapshot convention. +#[test] +fn captured_shortcut_gpu_snapshots() { + let Some(directory) = std::env::var_os("PHOTOCRAFT_SHORTCUT_SNAPSHOTS") else { + eprintln!("set PHOTOCRAFT_SHORTCUT_SNAPSHOTS to capture the shortcut dialog at 1x and 2x"); + return; + }; + let _gpu = photocraft_testkit::gpu_lock(); + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + for (scale, ppp) in [(1, 1.0), (2, 2.0)] { + let mut h = captured_ctrl_k(ppp, true); + let image = h.render().expect("render captured shortcut"); + assert_eq!((image.width(), image.height()), (1100 * scale, 760 * scale)); + image.save(directory.join(format!("ctrl-k-{scale}x.png"))).expect("save shortcut snapshot"); } } @@ -200,7 +273,13 @@ fn a_function_key_moved_off_a_panel_toggle_runs_its_new_command() { // row in the dialog is Photoshop's `window.panel.brushSettings` while the key ran the shell's // `window.toggle.brushSettings`. let (mut h, id) = dialog(1.0, "select.modify.feather", "feather"); - capture(&mut h, &crate::shortcuts::pretty("Shift+F6"), Key::ShiftLeft, Modifiers::NONE, Key::F5); + // Enter capture on the default row, then press plain F5 (not Shift+F5: #7190 records held modifiers). + h.get_by_label(&crate::shortcuts::pretty("Shift+F6")).click(); + h.run_steps(2); + key(&mut h, Key::F5, true, Modifiers::NONE); + h.run_steps(1); + key(&mut h, Key::F5, false, Modifiers::NONE); + h.run_steps(2); let message = field(&h, id, "message"); assert!(message.as_str().unwrap().contains("already in use"), "{message}"); crate::dialogs::confirm(h.state_mut(), id).unwrap(); diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index ffc1a7f6e5..0f47282213 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -274,7 +274,7 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc /// selection is filled with the background colour in the mask (Photoshop's Delete on a mask), and /// with no selection the mask itself is deleted, as Layer › Layer Mask › Delete, unless several /// layers are selected: then they are deleted. -fn delete_key_command(app: &PhotocraftApp, id: String) -> String { +pub(crate) fn delete_key_command(app: &PhotocraftApp, id: String) -> String { if id != "edit.clear" { return id; } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 00f1a120a3..9bb0e5df24 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -21,6 +21,8 @@ fn realistic() -> photocraft_engine::Session { s.execute("file.new", json!({"width": 240, "height": 160})).unwrap(); s.execute("layer.new.layer", json!({"name": "in-group"})).unwrap(); s.execute("layer.groupLayers", json!({"name": "group"})).unwrap(); + // Group Layers creates collapsed groups (#5571); the walk below expects it open. + s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap(); s.execute("type.create", json!({"x": 20, "y": 60, "text": "Hello"})).unwrap(); let paint = s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap(); s.execute("select.rect", json!({"x": 20, "y": 20, "width": 80, "height": 60})).unwrap(); @@ -356,6 +358,24 @@ fn alternate_keys_run_their_command() { assert!(take_log(&h.ctx).is_empty()); } +/// Ctrl+F4 closes the document beside Ctrl+W, as in Photoshop on Windows, through the same +/// guarded Close: unsaved changes still get the save prompt. +#[cfg(not(target_os = "macos"))] +#[test] +fn ctrl_f4_closes_the_document() { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + press(&mut h, "Cmd+F4"); + assert!(matches!(take_log(&h.ctx).as_slice(), [(id, Outcome::Ran)] if id == "file.close")); + assert!(h.state().discard.is_some(), "unsaved changes ask first"); + assert_eq!(h.state().session.documents().len(), 1); + h.state_mut().discard = None; + let st = h.state_mut().session.active_mut().unwrap(); + st.saved_revision = st.revision; + press(&mut h, "Cmd+F4"); + assert!(h.state().session.documents().is_empty(), "a saved document closes"); +} + /// #352: ⌥[ ⌥] ⌥, ⌥. and ⇧⌥[ walk the Layers panel's rows from the keyboard. #[test] fn alt_brackets_walk_the_layers_panel() { diff --git a/crates/ui-egui/src/shortcut_set_tests.rs b/crates/ui-egui/src/shortcut_set_tests.rs index b00f3839fc..2158a4209c 100644 --- a/crates/ui-egui/src/shortcut_set_tests.rs +++ b/crates/ui-egui/src/shortcut_set_tests.rs @@ -6,7 +6,7 @@ use std::cell::RefCell; use std::rc::Rc; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::{Value, json}; use crate::PhotocraftApp; @@ -126,3 +126,102 @@ fn export_and_summarize_write_the_dialogs_bindings() { // Nothing changed the preferences. assert!(h.state().session.prefs().shortcuts.is_empty()); } + +/// The Set dropdown's shown label: the set in use, with "(modified)" after an edit. +fn shown_set(h: &Harness<'_, PhotocraftApp>) -> String { + let at = h.query_all_by_label("Set:").next().expect("the Set label").rect().center(); + let combo = h + .query_all_by_role(egui::accesskit::Role::ComboBox) + .min_by(|a, b| a.rect().center().distance(at).total_cmp(&b.rect().center().distance(at))) + .expect("the Set dropdown"); + let node = combo.accesskit_node(); + node.value().or(node.label()).map(|s| s.to_string()).unwrap_or_default() +} + +/// Pick `label` in the Set dropdown, as the user does: open it, click the option. +fn pick_set(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + // The dialog settles where it opens first. + h.run_steps(6); + let at = h.query_all_by_label("Set:").next().expect("the Set label").rect().center(); + let at = h + .query_all_by_role(egui::accesskit::Role::ComboBox) + .min_by(|a, b| a.rect().center().distance(at).total_cmp(&b.rect().center().distance(at))) + .expect("the Set dropdown") + .rect() + .center(); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + let option = h.query_all_by_label(label).rfind(|n| n.accesskit_node().role() != egui::accesskit::Role::ComboBox); + option.expect("the set in the open list").click(); + h.run_steps(3); +} + +/// #7004: an action's F-key belongs to the action, so picking another set in the dialog keeps +/// it (as `useSet` does) and assigning one doesn't mark the set "(modified)". +#[test] +fn the_set_dropdown_keeps_action_function_keys_like_use_set() { + let (mut h, first, _) = dialog(vec![]); + h.state_mut().ui.dialogs.retain(|d| d.id != first); + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F9"}, "saveSet": "A"})).unwrap(); + // B, from a settings file written before sets left action keys out: it holds an old key for + // the action, and gives F2 to a command. + h.state_mut().session.edit_prefs(|p| { + let b = photocraft_engine::shortcut_sets::ShortcutSet { + shortcuts: [("actions.play:Sepia".to_string(), "F5".to_string()), ("layer.renameLayer".to_string(), "F2".to_string())].into(), + alternate_shortcuts: Default::default(), + }; + p.shortcut_sets.insert("B".into(), b); + }); + h.state_mut().run("edit.keyboardShortcuts", json!({"useSet": ""})).unwrap(); + crate::actions::assign_shortcut(h.state_mut(), "Sepia", "F2").unwrap(); + let ctx = h.ctx.clone(); + let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.run_steps(4); + assert_eq!(shown_set(&h), "PhotoCraft Defaults", "an action key doesn't modify the defaults"); + pick_set(&mut h, "A"); + assert_eq!(field(&h, id, "activeSet"), json!("A")); + assert_eq!(field(&h, id, "overrides"), json!({"edit.copy": "F9", "actions.play:Sepia": "F2"}), "A's keys, and the action's"); + assert_eq!(shown_set(&h), "A"); + pick_set(&mut h, "PhotoCraft Defaults"); + assert_eq!(field(&h, id, "overrides"), json!({"actions.play:Sepia": "F2"}), "kept switching to the defaults"); + pick_set(&mut h, "B"); + // What wins is what `useSet` gives: the action keeps F2, B's old action key is dropped and + // its own F2 binding stays. + let mut engine = h.state().session.prefs().clone(); + photocraft_engine::shortcut_sets::load(&mut engine, &json!({"useSet": "B"})).unwrap(); + assert_eq!(field(&h, id, "overrides"), json!(engine.shortcuts)); + assert_eq!(field(&h, id, "overrides"), json!({"actions.play:Sepia": "F2", "layer.renameLayer": "F2"})); + assert_eq!(shown_set(&h), "B", "B's old action key doesn't count either"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let p = h.state().session.prefs(); + assert_eq!((p.shortcut_set.as_str(), p.shortcuts.get("actions.play:Sepia").map(String::as_str)), ("B", Some("F2"))); + assert!(p.shortcut_sets.values().all(|s| !s.shortcuts.keys().any(|k| k.starts_with("actions.play:"))), "OK saves no action key in a set"); +} + +/// A real edit still marks the set "(modified)"; Save and New Set… store it without the actions' +/// F-keys. +#[test] +fn an_edit_still_marks_the_set_modified_and_sets_save_no_action_keys() { + let (mut h, first, _) = dialog(vec![]); + h.state_mut().ui.dialogs.retain(|d| d.id != first); + h.state_mut().run("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F9"}, "saveSet": "A"})).unwrap(); + crate::actions::assign_shortcut(h.state_mut(), "Sepia", "F2").unwrap(); + let ctx = h.ctx.clone(); + let id = crate::menus::invoke(h.state_mut(), &ctx, "edit.keyboardShortcuts", json!({})).unwrap()["dialog"].as_u64().unwrap(); + h.run_steps(4); + assert_eq!(shown_set(&h), "A"); + set_field(&mut h, id, "overrides", json!({"edit.copy": "F8", "actions.play:Sepia": "F2"})); + assert_eq!(shown_set(&h), "A (modified)"); + click(&mut h, "Save"); + assert_eq!(field(&h, id, "sets")["A"]["shortcuts"], json!({"edit.copy": "F8"})); + assert_eq!(shown_set(&h), "A"); + click(&mut h, "New Set…"); + set_field(&mut h, id, "newSetName", json!("C")); + click(&mut h, "Create"); + assert_eq!(field(&h, id, "sets")["C"]["shortcuts"], json!({"edit.copy": "F8"})); + assert_eq!(shown_set(&h), "C"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ad8d6d92a..3300b9d92e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -38,7 +38,8 @@ pub fn parse(s: &str) -> Option { ";" => Some(Key::Semicolon), "'" => Some(Key::Quote), "]" => Some(Key::CloseBracket), - name => Key::from_name(name), + // Case-insensitive too: bindings saved as `Shift+Pageup` before #6827 still work. + name => Key::from_name(name).or_else(|| Key::ALL.iter().find(|k| k.name().eq_ignore_ascii_case(name)).copied()), }?; Some(KeyboardShortcut::new(mods, key)) } @@ -195,18 +196,23 @@ fn new_layer_without_dialog(app: &mut PhotocraftApp, ctx: &egui::Context) -> boo } fn nav_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { - for id in NAV_COMMANDS { - let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| app.session.prefs().shortcut(id, c.3)); + let Some(id) = nav_shortcut(app, ctx) else { return false }; + if crate::menus::is_enabled(app, id) { + let _ = crate::menus::invoke(app, ctx, id, json!({})); + } + true +} + +/// The [`NAV_COMMANDS`] entry whose shortcut (or an Edit › Keyboard Shortcuts override) was +/// pressed this frame, with the key press consumed. A view with its own zoom, like Liquify's +/// preview, steps it with the same keys (#7210). +pub(crate) fn nav_shortcut(app: &PhotocraftApp, ctx: &egui::Context) -> Option<&'static str> { + NAV_COMMANDS.into_iter().find(|id| { + let main = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == *id).and_then(|c| app.session.prefs().shortcut(id, c.3)); let keys: Vec = main.into_iter().map(str::to_string).chain(app.session.prefs().alternates(id, main)).filter_map(|k| parse(&k)).collect(); - if keys.iter().any(|sc| consume(ctx, sc)) { - if crate::menus::is_enabled(app, id) { - let _ = crate::menus::invoke(app, ctx, id, json!({})); - } - return true; - } - } - false + keys.iter().any(|sc| consume(ctx, sc)) + }) } /// The key press a clipboard event stands for outside text fields, with `mods` held: Copy, Cut @@ -223,6 +229,37 @@ fn clipboard_press(e: &egui::Event, mods: Modifiers, is_windows: bool) -> Option } } +/// Text is selected in a label (an error notice's message, say): dragged across, double- or +/// triple-clicked or shift-clicked, as egui selects it. ⌘C then copies that text, as in a text +/// field, rather than running Edit › Copy on the layer (#7807). A plain click on a label leaves an +/// empty selection behind, which must not take ⌘C from the document. Read once a frame, from the +/// frame before. +fn label_text_selected(ctx: &egui::Context) -> bool { + let id = egui::Id::new("pc-label-text-selected"); + let any = ctx.plugin_opt::().is_some_and(|p| p.lock().has_selection()); + let (selects, pressed) = ctx.input(|i| { + let p = &i.pointer; + let selects = (p.primary_down() && p.is_decidedly_dragging()) + || p.button_double_clicked(egui::PointerButton::Primary) + || p.button_triple_clicked(egui::PointerButton::Primary) + || (p.primary_pressed() && i.modifiers.shift); + (selects, p.primary_pressed()) + }); + let selected = any && (selects || (!pressed && ctx.data(|d| d.get_temp::(id)).unwrap_or(false))); + ctx.data_mut(|d| d.insert_temp(id, selected)); + selected +} + +/// ⌘C, as egui-winit's `Event::Copy` or as a key press (the web, the Mac menu's key equivalent), +/// without ⇧ (Copy Merged) or ⌥. +fn plain_copy(e: &egui::Event, mods: Modifiers) -> bool { + match e { + egui::Event::Copy => !mods.shift && !mods.alt, + egui::Event::Key { key: Key::C, pressed: true, modifiers: m, .. } => m.command && !m.shift && !m.alt, + _ => false, + } +} + /// egui-winit turns ⌘C / ⌘X / ⌘V into `Event::Copy` / `Cut` / `Paste` and drops the key press, and /// drops ⌘V entirely when the clipboard holds no text (an image). Outside text fields, give the /// pixel commands their key presses back: Copy/Cut/Paste become ⌘C/⌘X/⌘V presses (with ⇧/⌥ as @@ -239,7 +276,11 @@ fn clipboard_press(e: &egui::Event, mods: Modifiers, is_windows: bool) -> Option /// /// Ctrl+Insert copies and Shift+Insert pastes: egui-winit maps them on Windows (a Shift+Insert /// release without a paste event pastes the image, like ⌘V), and we map them here on Linux. +/// +/// With text selected in a label ([`label_text_selected`]) ⌘C copies the text; ⌘X and ⌘V stay +/// the document's. pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInput) { + let text_selected = label_text_selected(ctx); let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; if cfg!(target_os = "linux") { raw.events.retain_mut(|e| { @@ -302,6 +343,10 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu ctx.data_mut(|d| d.insert_temp(command_lately, true)); } } + if text_selected && plain_copy(&e, mods) { + out.push(egui::Event::Copy); + continue; + } if let Some((key, held)) = clipboard_press(&e, mods, cfg!(target_os = "windows")) { if key == Key::V { ctx.data_mut(|d| { @@ -375,9 +420,16 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.camera_raw.is_some() { return; } + // A foreground save (Save in Background off, #6207) blocks the app until the file is written; + // only Esc reaches it, to cancel while it still encodes (see `jobs_ui::tick`). + if crate::jobs_ui::foreground_save(app).is_some() { + return; + } // An open menu owns the keyboard (arrows, ↩, Esc), like a native menu; so does the menu bar // once Alt or F10 put the keyboard on it (#4988), and an open dropdown list, whose letters - // pick an option rather than a tool (#1481). + // pick an option rather than a tool (#1481). A command shortcut (⌘D, F7…) takes the keyboard + // off the bar first and runs, as with a native menu bar (#6573). + crate::menu_keys::leave_for_shortcut(ctx); if crate::menu_nav::is_open(ctx) || crate::menu_keys::is_focused(ctx) || crate::widgets::dropdown_list_open(ctx) { return; } @@ -555,6 +607,14 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if crate::guide_select::keys(app, ctx) { return; } + // A slice selected with Slice Select: ⌫/Delete deletes the slice, not the layer (#7862). + if crate::slice_ui::keys(app, ctx) { + return; + } + // A selected smart filter row: ⌫/Delete removes the filter, not its smart object (#4751). + if crate::smart_ui::keys(app, ctx) { + return; + } // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the // Hand key is held, before F's own Cycle Screen Mode. if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { @@ -704,6 +764,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // Page Up / Page Down (⌘ sideways, ⇧ a small step), Home and End scroll the canvas (#8057). + if crate::page_keys::handle(app, ctx) { + return; + } // 1–0 set opacity, ⇧1–0 flow or fill (`opacity_keys`). crate::opacity_keys::handle(app, ctx); // ⇧+ / ⇧− step the blend mode (`blend_keys`). diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index d0e0cfdec4..bf8adf549f 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -296,6 +296,15 @@ fn image_size(ui: &mut egui::Ui, f: &mut Map) { // Without resampling the pixel count is fixed: physical size follows resolution. f.insert("width".into(), json!(ow)); f.insert("height".into(), json!(oh)); + } else if !matches!(unit.as_str(), "px" | "percent") { + // Resampling in a physical unit keeps the print size: the pixels follow the + // resolution (Photoshop). In pixels or percent the pixel count stays. + let old = f.get("resolution").and_then(Value::as_f64).unwrap_or(num(f, "__origRes")); + if old > 0.0 && old.is_finite() { + for key in ["width", "height"] { + f.insert(key.into(), json!((num(f, key) * r / old).max(1.0))); + } + } } f.insert("resolution".into(), json!(r)); } @@ -645,9 +654,14 @@ mod tests { /// Types `text` into the Width field and leaves it with Tab. fn type_width(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, text: &str) { + type_into(h, 0, text); + } + + /// Types `text` into the `n`th number field (Width, Height, Resolution) and leaves it with Tab. + fn type_into(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, n: usize, text: &str) { use egui::accesskit::Role; use egui_kittest::kittest::Queryable; - let width = h.query_all_by_role(Role::SpinButton).next().map(|n| n.rect()).expect("the Width field"); + let width = h.query_all_by_role(Role::SpinButton).nth(n).map(|n| n.rect()).expect("the number field"); h.hover_at(width.center()); h.run_steps(1); h.drag_at(width.center()); @@ -696,6 +710,32 @@ mod tests { assert!((doc.resolution_dpi - 50.0).abs() < 1e-3, "{}", doc.resolution_dpi); } + /// #6979: with Resample on, a new Resolution keeps the print size in a physical unit and + /// resamples the pixels (Photoshop): 720 x 576 px at 72 ppi is 10 x 8 in, so 300 ppi is + /// 3000 x 2400 px. In pixels the pixel count stays. + #[test] + fn resample_on_resolution_keeps_the_physical_size() { + for (unit, size) in [("in", (3000.0, 2400.0)), ("cm", (3000.0, 2400.0)), ("px", (720.0, 576.0))] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 720, "height": 576, "resolution": 72})).unwrap(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .build_ui_state(|ui, app: &mut PhotocraftApp| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), "image.imageSize").unwrap(); + let d = h.state_mut().ui.dialog_mut(id).unwrap(); + d.fields.insert("resample".into(), json!("bicubic")); + d.fields.insert("__unit".into(), json!(unit)); + h.run_steps(3); + type_into(&mut h, 2, "300"); + let f = fields(&h); + assert_eq!((num(&f, "width"), num(&f, "height"), num(&f, "resolution")), (size.0, size.1, 300.0), "{unit}: {f:?}"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!((doc.size, doc.resolution_dpi), (photocraft_doc::Size::new(size.0 as u32, size.1 as u32), 300.0), "{unit}"); + } + } + /// #4162: a document wider than 30000 units in a physical unit (40000 px at 72 ppi is 40000 pt) /// kept its size in the fields and on OK; the fields clamped it to 30000 on open, so OK with /// no edit resized (Image Size) or cropped (Canvas Size) it. diff --git a/crates/ui-egui/src/slice_ui.rs b/crates/ui-egui/src/slice_ui.rs index 1e75bbd714..78e19f4be3 100644 --- a/crates/ui-egui/src/slice_ui.rs +++ b/crates/ui-egui/src/slice_ui.rs @@ -29,8 +29,67 @@ pub struct SliceUi { /// A gesture in progress. #[derive(Clone, Debug, PartialEq)] pub enum SliceDrag { - New { start: [f64; 2], cur: [f64; 2] }, - Move { id: u32, start: [f64; 2], cur: [f64; 2], rect: photocraft_geom::Rect }, + New { + start: [f64; 2], + cur: [f64; 2], + }, + Move { + id: u32, + start: [f64; 2], + cur: [f64; 2], + rect: photocraft_geom::Rect, + }, + /// A handle of the selected slice: `handle` is −1, 0 or 1 per axis (the left, untouched or + /// right edge; the top, untouched or bottom edge). + Resize { + id: u32, + handle: [i8; 2], + start: [f64; 2], + cur: [f64; 2], + rect: photocraft_geom::Rect, + }, +} + +/// How far from a handle (screen points) a press still grabs it. +const HANDLE_REACH: f64 = 5.0; + +/// The handle of `r` at document point `p`, within `reach` document pixels: the corners first, +/// then the edge middles, as the overlay draws them. +fn handle_at(r: photocraft_geom::Rect, p: [f64; 2], reach: f64) -> Option<[i8; 2]> { + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + let at = |h: [i8; 2]| { + let x = match h[0] { + -1 => x0, + 1 => x1, + _ => (x0 + x1) / 2.0, + }; + let y = match h[1] { + -1 => y0, + 1 => y1, + _ => (y0 + y1) / 2.0, + }; + (p[0] - x).abs() <= reach && (p[1] - y).abs() <= reach + }; + [[-1, -1], [1, -1], [-1, 1], [1, 1], [0, -1], [0, 1], [-1, 0], [1, 0]].into_iter().find(|h| at(*h)) +} + +/// `r` with the edges `handle` grabs moved by (`dx`, `dy`), turned the right way round when an +/// edge is dragged past the opposite one, and at least 1 × 1. +fn resized(r: photocraft_geom::Rect, handle: [i8; 2], dx: i32, dy: i32) -> photocraft_geom::Rect { + let (mut x0, mut y0, mut x1, mut y1) = (r.x0, r.y0, r.x1, r.y1); + match handle[0] { + -1 => x0 = x0.saturating_add(dx), + 1 => x1 = x1.saturating_add(dx), + _ => {} + } + match handle[1] { + -1 => y0 = y0.saturating_add(dy), + 1 => y1 = y1.saturating_add(dy), + _ => {} + } + let (x0, x1) = (x0.min(x1), x0.max(x1).max(x0.min(x1).saturating_add(1))); + let (y0, y1) = (y0.min(y1), y0.max(y1).max(y0.min(y1).saturating_add(1))); + photocraft_geom::Rect::new(x0, y0, x1, y1) } fn is_slice_tool(t: Tool) -> bool { @@ -60,23 +119,39 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return true }; let locked = app.session.file_menu.slices_locked; match ev { + // View › Lock Slices: neither tool draws, selects or moves a slice, and the press says + // why, as Photoshop's error does (#7865). + ToolEvent::Down { .. } if locked => { + app.ui.slices.selected = None; + app.ui.status = tl!("Slices are locked (View › Lock Slices)").to_string(); + app.ui.status_error = true; + } ToolEvent::Down { x, y, .. } => { if tool == Tool::Slice { - if !locked { - app.ui.slices.drag = Some(SliceDrag::New { start: [x, y], cur: [x, y] }); - } + app.ui.slices.drag = Some(SliceDrag::New { start: [x, y], cur: [x, y] }); } else { + // A handle of the selected user slice resizes it (#7863). + let reach = HANDLE_REACH / f64::from(app.point_zoom().max(0.01)); + let grabbed = app.ui.slices.selected.filter(|_| !locked).and_then(|n| { + let r = slices::resolve(&doc).into_iter().find(|r| r.number == n && r.origin == SliceOrigin::User)?; + Some((r.id?, r.rect, handle_at(r.rect, [x, y], reach)?)) + }); + if let Some((id, rect, handle)) = grabbed { + app.ui.slices.drag = Some(SliceDrag::Resize { id, handle, start: [x, y], cur: [x, y], rect }); + return true; + } let hit = slice_at(&doc, x, y); app.ui.slices.selected = hit.as_ref().map(|r| r.number); + // The whole slice moves, also when the press hits one piece of a covered slice. if let Some(r) = hit - && let Some(id) = r.id.filter(|_| !locked && r.origin == SliceOrigin::User) + && let Some(sl) = r.id.and_then(|id| doc.slices.get(id)).filter(|_| r.origin == SliceOrigin::User) { - app.ui.slices.drag = Some(SliceDrag::Move { id, start: [x, y], cur: [x, y], rect: r.rect }); + app.ui.slices.drag = Some(SliceDrag::Move { id: sl.id, start: [x, y], cur: [x, y], rect: sl.rect }); } } } ToolEvent::Move { x, y, .. } => match &mut app.ui.slices.drag { - Some(SliceDrag::New { cur, .. }) | Some(SliceDrag::Move { cur, .. }) => *cur = [x, y], + Some(SliceDrag::New { cur, .. }) | Some(SliceDrag::Move { cur, .. }) | Some(SliceDrag::Resize { cur, .. }) => *cur = [x, y], None => {} }, ToolEvent::Up { x, y } => match app.ui.slices.drag.take() { @@ -100,6 +175,13 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) - } } } + Some(SliceDrag::Resize { id, handle, start, rect, .. }) => { + let r = resized(rect, handle, (x - start[0]).round() as i32, (y - start[1]).round() as i32); + if r != rect && app.run("slice.set", json!({"slice": id, "rect": [r.x0, r.y0, r.width(), r.height()]})).is_ok() { + let d = app.session.active().map(|d| d.doc.clone()); + app.ui.slices.selected = d.and_then(|d| slices::resolve(&d).into_iter().find(|s| s.id == Some(id)).map(|s| s.number)); + } + } None => {} }, } @@ -190,6 +272,10 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform let r = rect.translate((cur[0] - start[0]).round() as i32, (cur[1] - start[1]).round() as i32); painter.rect_stroke(to_screen(r), 0.0, Stroke::new(2.0, selected_color), egui::StrokeKind::Inside); } + Some(SliceDrag::Resize { handle, start, cur, rect, .. }) => { + let r = resized(*rect, *handle, (cur[0] - start[0]).round() as i32, (cur[1] - start[1]).round() as i32); + painter.rect_stroke(to_screen(r), 0.0, Stroke::new(2.0, selected_color), egui::StrokeKind::Inside); + } None => {} } } @@ -206,6 +292,39 @@ fn selected_id(app: &mut PhotocraftApp, promote: bool) -> Option { } } +/// Delete or Backspace with no modifiers, with the Slice Select tool and a slice selected, +/// deletes that slice, as in Photoshop (#7862). It never reaches Layer › Delete or Edit › Clear. +/// An auto slice has nothing to delete, and View › Lock Slices refuses with a message. +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Text fields and widgets that delete with these keys themselves keep them. + if app.ui.tool != Tool::SliceSelect + || app.ui.slices.selected.is_none() + || app.ui.text_edit.is_some() + || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) + { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + let result = if app.session.file_menu.slices_locked { + Err(tl!("Slices are locked (View › Lock Slices)").to_string()) + } else { + match selected_id(app, false) { + Some(id) => app.run("slice.delete", json!({"slice": id})).map(|_| app.ui.slices.selected = None), + // An auto slice is made from the user slices around it: it goes when they do. + None => Ok(()), + } + }; + if let Err(e) = result { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + /// Slice Options dialog for the selected slice (auto slices are promoted on OK). pub fn open_options(app: &mut PhotocraftApp) -> Result { let n = app.ui.slices.selected.ok_or("select a slice with the Slice Select tool")?; @@ -323,6 +442,154 @@ mod tests { assert!(doc.slices.list.iter().any(|s| s.name == "footer")); } + /// #7865: with View › Lock Slices on, Slice Select doesn't select (or move) a slice, and the + /// Slice tool doesn't start one; both say why, as Photoshop's error does. + #[test] + fn locked_slices_cannot_be_selected() { + let mut app = app(); + let m = egui::Modifiers::NONE; + app.ui.tool = Tool::Slice; + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 40.0, y: 30.0 }, m); + let rect = app.session.active().unwrap().doc.slices.list[0].rect; + app.ui.slices.selected = None; + app.run("view.lockSlices", json!({"on": true})).unwrap(); + app.ui.tool = Tool::SliceSelect; + pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Move { x: 30.0, y: 30.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 30.0, y: 30.0 }, m); + assert_eq!(app.ui.slices.selected, None, "not selected"); + assert_eq!(app.session.active().unwrap().doc.slices.list[0].rect, rect, "not moved"); + assert!(app.ui.status_error && app.ui.status.contains("locked"), "{}", app.ui.status); + // The Slice tool says so too. + app.ui.status_error = false; + app.ui.tool = Tool::Slice; + pointer(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 80.0, y: 70.0 }, m); + assert_eq!(app.session.active().unwrap().doc.slices.list.len(), 1); + assert!(app.ui.status_error && app.ui.status.contains("locked"), "{}", app.ui.status); + // Unlocked again, Slice Select selects it. + app.run("view.lockSlices", json!({"on": false})).unwrap(); + app.ui.tool = Tool::SliceSelect; + pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, m); + assert!(app.ui.slices.selected.is_some()); + } + + #[test] + fn dragging_a_piece_moves_the_whole_covered_slice() { + // #7770: the slice under a later one is drawn as pieces; dragging one moves all of it. + let mut app = app(); + app.run("slice.new", json!({"rect": [0, 0, 60, 60]})).unwrap(); + app.run("slice.new", json!({"rect": [40, 40, 20, 20]})).unwrap(); + app.ui.tool = Tool::SliceSelect; + let m = egui::Modifiers::NONE; + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 50.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 15.0, y: 50.0 }, m); + let doc = app.session.active().unwrap().doc.clone(); + assert_eq!(doc.slices.list[0].rect, photocraft_geom::Rect::new(5, 0, 65, 60)); + } + + #[test] + fn handles_resize_the_edges_they_hang_off() { + let r = photocraft_geom::Rect::new(10, 10, 40, 30); + assert_eq!(handle_at(r, [10.5, 9.0], 2.0), Some([-1, -1])); + assert_eq!(handle_at(r, [25.0, 30.0], 2.0), Some([0, 1])); + assert_eq!(handle_at(r, [40.0, 20.0], 2.0), Some([1, 0])); + assert_eq!(handle_at(r, [25.0, 20.0], 2.0), None, "the middle is no handle"); + assert_eq!(resized(r, [1, 0], 5, 9), photocraft_geom::Rect::new(10, 10, 45, 30), "the right edge only"); + assert_eq!(resized(r, [-1, 1], -4, 6), photocraft_geom::Rect::new(6, 10, 40, 36)); + assert_eq!(resized(r, [-1, 0], 50, 0), photocraft_geom::Rect::new(40, 10, 60, 30), "dragged past the other edge"); + assert_eq!(resized(r, [1, 0], -30, 0), photocraft_geom::Rect::new(10, 10, 11, 30), "never empty"); + } + + /// #7863: with Slice Select, dragging a handle of the selected slice resizes it; the middle + /// still moves it, and locked slices keep their size. + #[test] + fn slice_select_handles_resize_the_slice() { + let mut app = app(); + let m = egui::Modifiers::NONE; + app.ui.tool = Tool::Slice; + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 40.0, y: 30.0 }, m); + app.ui.tool = Tool::SliceSelect; + let rect = |app: &PhotocraftApp| app.session.active().unwrap().doc.slices.list[0].rect; + // The bottom-right corner. + pointer(&mut app, ToolEvent::Down { x: 40.0, y: 30.0, pressure: 1.0 }, m); + assert!(matches!(app.ui.slices.drag, Some(SliceDrag::Resize { handle: [1, 1], .. }))); + pointer(&mut app, ToolEvent::Move { x: 50.0, y: 45.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 50.0, y: 45.0 }, m); + assert_eq!(rect(&app), photocraft_geom::Rect::new(10, 10, 50, 45)); + assert!(app.ui.slices.selected.is_some(), "still selected"); + // The left edge's middle. + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 27.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 4.0, y: 60.0 }, m); + assert_eq!(rect(&app), photocraft_geom::Rect::new(4, 10, 50, 45), "only the left edge moves"); + // Inside, away from the handles, it still moves. + pointer(&mut app, ToolEvent::Down { x: 30.0, y: 20.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 32.0, y: 21.0 }, m); + assert_eq!(rect(&app), photocraft_geom::Rect::new(6, 11, 52, 46)); + // Locked: the handle doesn't resize. + app.run("view.lockSlices", json!({"on": true})).unwrap(); + pointer(&mut app, ToolEvent::Down { x: 52.0, y: 46.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 70.0, y: 70.0 }, m); + assert_eq!(rect(&app), photocraft_geom::Rect::new(6, 11, 52, 46)); + } + + /// Delete through the app's shortcut handling, as the canvas gets it. + fn press(app: &mut PhotocraftApp, key: egui::Key) { + let ctx = egui::Context::default(); + let m = egui::Modifiers::NONE; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + } + + /// #7862: with Slice Select, Delete (or Backspace) deletes the selected slice, never the + /// layer: in a one-layer document that used to fail with "a document must keep at least one + /// layer". Locked slices refuse; an auto slice has nothing to delete. + #[test] + fn delete_deletes_the_selected_slice_not_the_layer() { + let m = egui::Modifiers::NONE; + for key in [egui::Key::Delete, egui::Key::Backspace] { + let mut app = app(); + let layers = app.session.active().unwrap().doc.layers.len(); + app.ui.tool = Tool::Slice; + pointer(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 40.0, y: 30.0 }, m); + app.ui.tool = Tool::SliceSelect; + pointer(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, m); + assert!(app.ui.slices.selected.is_some()); + + // Locked: the slice stays, and says why. + app.run("view.lockSlices", json!({"on": true})).unwrap(); + press(&mut app, key); + assert_eq!(app.session.active().unwrap().doc.slices.list.len(), 1, "{key:?}: locked"); + assert!(app.ui.status_error && app.ui.status.contains("locked"), "{key:?}: {}", app.ui.status); + app.run("view.lockSlices", json!({"on": false})).unwrap(); + app.ui.status_error = false; + + press(&mut app, key); + let doc = app.session.active().unwrap().doc.clone(); + assert!(doc.slices.list.is_empty(), "{key:?}: the slice is deleted"); + assert_eq!(doc.layers.len(), layers, "{key:?}: the layer stays"); + assert!(!app.ui.status_error, "{key:?}: {}", app.ui.status); + assert_eq!(app.ui.slices.selected, None); + + // An auto slice: nothing to delete, and still not the layer. + pointer(&mut app, ToolEvent::Down { x: 90.0, y: 75.0, pressure: 1.0 }, m); + pointer(&mut app, ToolEvent::Up { x: 90.0, y: 75.0 }, m); + press(&mut app, key); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "{key:?}: auto slice"); + assert!(!app.ui.status_error, "{key:?}: {}", app.ui.status); + } + } + #[test] fn locked_slices_ignore_gestures() { let mut app = app(); diff --git a/crates/ui-egui/src/smart_filter_dialog.rs b/crates/ui-egui/src/smart_filter_dialog.rs new file mode 100644 index 0000000000..cf184c5671 --- /dev/null +++ b/crates/ui-egui/src/smart_filter_dialog.rs @@ -0,0 +1,245 @@ +//! Per-filter blending and shared target validation for Smart Filter editors. +//! Preview and commit dispatch the existing engine commands against the captured filter. + +use std::sync::{Arc, Weak}; + +use photocraft_color::BlendMode; +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::{Map, Value, json}; + +use crate::{PhotocraftApp, state::DialogKind, theme::Tokens, widgets}; + +pub const COMMAND: &str = "layer.smartFilter.blendingOptions"; +const TARGET: &str = "__smartFilter"; + +/// Private document snapshots; public dialog fields carry an opaque counter, never an address. +#[derive(Default)] +pub(crate) struct Targets { + next: u64, + snapshots: Vec<(u64, Weak)>, +} + +impl Targets { + /// Remember a weak handle to `doc` and return the opaque token that stands for it in a dialog's target. + fn capture(&mut self, doc: &Arc) -> Result { + self.next = self.next.checked_add(1).ok_or("Smart Filter target counter exhausted")?; + self.snapshots.retain(|(_, doc)| doc.strong_count() > 0); + // Dialogs normally are modal. Bound retained snapshots even if automation opens many. + if self.snapshots.len() >= 64 { + self.snapshots.remove(0); + } + self.snapshots.push((self.next, Arc::downgrade(doc))); + Ok(self.next) + } + + /// Whether `token` was captured from this very document allocation, not just one with the same id. + fn matches(&self, token: u64, doc: &Arc) -> bool { + self.snapshots.iter().find(|(id, _)| *id == token).and_then(|(_, doc)| doc.upgrade()).is_some_and(|old| Arc::ptr_eq(&old, doc)) + } +} + +/// Identify the Smart Filter at `layer`/`index` (document, revision, filter) so a stale dialog can be told apart from a current one. +fn snapshot(app: &PhotocraftApp, layer: LayerId, index: usize) -> Result { + let st = app.session.active().ok_or("no document open")?; + if app.session.job_on(st.doc.id).is_some() { + return Err(tl!("Wait for the current job to finish.").into()); + } + let sm = match &st.doc.layer(layer).ok_or("Smart Object no longer exists")?.content { + LayerContent::Smart(sm) => sm, + _ => return Err("the layer is not a Smart Object".into()), + }; + let filter = sm.smart_filters.get(index).ok_or("Smart Filter no longer exists")?; + Ok(json!({"doc": st.doc.id.0, "revision": st.revision, "layer": layer.0, "index": index, "filter": filter})) +} + +/// The `__smartFilter` dialog field: the filter's snapshot plus a guard token for the document it was taken from. +pub(crate) fn target(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result { + let mut target = snapshot(app, layer, index)?; + let doc = &app.session.active().ok_or("no document open")?.doc; + let token = app.smart_filter_targets.capture(doc)?; + if let Some(fields) = target.as_object_mut() { + fields.insert("guard".into(), json!(token)); + } + Ok(target) +} + +/// Revision/filter identity catches edits; the weak snapshot also rejects a same-id reopen. +pub(crate) fn validate_target(app: &PhotocraftApp, target: &Value) -> Result<(LayerId, usize), String> { + let layer = LayerId(target.get("layer").and_then(Value::as_u64).ok_or("invalid Smart Filter target")?); + let index = target.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("invalid Smart Filter index")?; + let token = target.get("guard").and_then(Value::as_u64).ok_or("invalid Smart Filter snapshot")?; + let doc = &app.session.active().ok_or("no document open")?.doc; + let current = snapshot(app, layer, index)?; + let mut captured = target.clone(); + if let Some(fields) = captured.as_object_mut() { + fields.remove("guard"); + } + if current != captured || !app.smart_filter_targets.matches(token, doc) { + return Err(tl!("The document or Smart Filter changed. Reopen its dialog.").into()); + } + Ok((layer, index)) +} + +/// Open the Blending Options dialog for one Smart Filter, starting from its recorded mode and opacity (shown as a percentage). +pub fn open(app: &mut PhotocraftApp, layer: LayerId, index: usize) -> Result { + let target = self::target(app, layer, index)?; + let st = app.session.active().ok_or("no document open")?; + let Some(LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else { return Err("the layer is not a Smart Object".into()) }; + let filter = sm.smart_filters.get(index).ok_or("Smart Filter no longer exists")?; + let mut fields = Map::new(); + fields.insert("__command".into(), json!(COMMAND)); + fields.insert("__label".into(), json!("Blending Options")); + fields.insert("__filterName".into(), json!(crate::smart_ui::filter_label(filter))); + fields.insert("__filter".into(), json!(true)); + fields.insert("__preview".into(), json!(true)); + fields.insert(TARGET.into(), target); + fields.insert("blend".into(), json!(filter.blend.label())); + fields.insert("opacity".into(), json!(filter.opacity * 100.0)); + let fit = (220.0 / st.doc.size.width.max(st.doc.size.height).max(1) as f64 * 100.0).min(100.0); + fields.insert("__zoom".into(), json!(fit)); + fields.insert("__imageSize".into(), json!([st.doc.size.width, st.doc.size.height])); + app.filter_preview = None; + Ok(app.ui.open_dialog(DialogKind::Command, fields)) +} + +/// Whether `fields` belong to this Blending Options dialog rather than another Smart Filter editor. +pub fn owns(fields: &Map) -> bool { + fields.get("__command").and_then(Value::as_str) == Some(COMMAND) && fields.contains_key(TARGET) +} + +/// The dialog title, naming the filter being edited. +pub(crate) fn title(fields: &Map) -> String { + crate::i18n::fmt(tl!("Blending Options ({filter})"), &[("filter", fields.get("__filterName").and_then(Value::as_str).unwrap_or_default())]) +} + +/// UI opacity is always percent; the engine gets a fraction, including 1% = 0.01. +fn blend_params(fields: &Map) -> Result { + let opacity = fields + .get("opacity") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && (0.0..=100.0).contains(v)) + .ok_or(tl!("Opacity must be between 0% and 100%."))?; + let blend = fields.get("blend").and_then(Value::as_str).ok_or("choose a blend mode")?; + Ok(json!({"blend": blend, "opacity": opacity / 100.0})) +} + +/// The command a confirmed Smart Filter dialog runs on the validated target: its blending, or `layer.smartFilter.setParams` for a parameter editor. +pub(crate) fn edit_command(app: &PhotocraftApp, fields: &Map) -> Result<(String, Value), String> { + let (layer, index) = validate_target(app, fields.get(TARGET).ok_or("no Smart Filter target")?)?; + if owns(fields) { + let mut params = blend_params(fields)?; + if let Some(p) = params.as_object_mut() { + p.insert("layer".into(), json!(layer.0)); + p.insert("index".into(), json!(index)); + } + Ok((COMMAND.into(), params)) + } else { + Ok(("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "index": index, "params": crate::filter_dialog::params_of(fields)}))) + } +} + +/// Dissolve has no per-pixel coverage sampling in Smart Filter blending yet (docs/smart-filters.md). +fn mode_note(blend: Option<&str>) -> Option<&'static str> { + (blend == Some(BlendMode::Dissolve.label())).then(|| tl!("Dissolve is approximated: it blends without Photoshop's dither.")) +} + +/// The Blending Options form: Mode (with the Dissolve note), Opacity, Preview and the result viewport. +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map) { + ui.horizontal(|ui| { + ui.label(tl!("Mode")); + let mut blend = fields.get("blend").and_then(Value::as_str).unwrap_or("Normal").to_string(); + let options: Vec<_> = BlendMode::LAYER_MODES.iter().map(|m| (m.label().to_string(), m.label())).collect(); + if widgets::dropdown(ui, "smart-filter-blend", &mut blend, &options, 200.0) { + fields.insert("blend".into(), json!(blend)); + } + }); + if let Some(note) = mode_note(fields.get("blend").and_then(Value::as_str)) { + ui.label(egui::RichText::new(note).color(Tokens::get(ui.ctx()).text_faint).size(11.0)); + } + let mut opacity = fields.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32; + if widgets::slider_row(ui, tl!("Opacity"), &mut opacity, 0.0..=100.0, "%", None).changed() { + fields.insert("opacity".into(), json!(opacity)); + } + let mut preview = fields.get("__preview").and_then(Value::as_bool).unwrap_or(true); + if widgets::checkbox(ui, &mut preview, tl!("Preview")).changed() { + fields.insert("__preview".into(), json!(preview)); + } + if let Err(e) = fields.get(TARGET).ok_or("no Smart Filter target").map_err(str::to_string).and_then(|t| validate_target(app, t)) { + ui.colored_label(Tokens::get(ui.ctx()).danger, e); + return; + } + viewport(app, ui, fields, preview); +} + +#[derive(Clone)] +struct ViewImage { + doc: Arc, + texture: egui::TextureHandle, +} + +/// Show the document as the dialog will leave it (the live preview, or the unchanged document while Preview is off) as a fixed-size thumbnail. +fn viewport(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map, preview: bool) { + let Some(st) = app.session.active() else { return }; + let doc = if preview { + app.filter_preview.as_ref().filter(|p| p.key.doc == st.doc.id && p.key.revision == st.revision && !p.committing).and_then(|p| p.result.clone()) + } else { + Some(st.doc.clone()) + }; + let Some(doc) = doc else { + ui.allocate_ui(egui::vec2(360.0, 220.0), |ui| { + ui.centered_and_justified(|ui| { + ui.spinner(); + }); + }); + return; + }; + let key = ui.id().with("smart-filter-view-image"); + let mut image = ui.ctx().data(|d| d.get_temp::(key)); + if image.as_ref().is_none_or(|i| !Arc::ptr_eq(&i.doc, &doc)) { + // Fixed thumbnail size and content identity: never composite a full surface per frame. + let buffer = photocraft_compose::thumbnail_buffer(&doc, 256); + match app.session.color.canvas_display(&doc) { + Ok(display) => { + let thumb = display.to_rgba8(&buffer); + let texture = ui.ctx().load_texture( + "smart-filter-view-image", + egui::ColorImage::from_rgba_unmultiplied([thumb.width as usize, thumb.height as usize], &thumb.pixels), + egui::TextureOptions::LINEAR, + ); + image = Some(ViewImage { doc, texture }); + if let Some(i) = &image { + ui.ctx().data_mut(|d| d.insert_temp(key, i.clone())); + } + } + Err(e) => { + ui.colored_label(Tokens::get(ui.ctx()).danger, e.to_string()); + return; + } + } + } + let Some(image) = image else { return }; + let zoom = fields.get("__zoom").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.1, 800.0) as f32; + let size = fields + .get("__imageSize") + .and_then(Value::as_array) + .and_then(|a| Some(egui::vec2(a.first()?.as_u64()? as f32, a.get(1)?.as_u64()? as f32))) + .unwrap_or(image.texture.size_vec2()); + egui::Frame::new().fill(Tokens::get(ui.ctx()).canvas).stroke(egui::Stroke::new(1.0, Tokens::get(ui.ctx()).field_border)).show(ui, |ui| { + ui.set_width(360.0); + egui::ScrollArea::both().id_salt("smart-filter-image").auto_shrink([false, false]).min_scrolled_height(220.0).max_height(220.0).show(ui, |ui| { + ui.add(egui::Image::new(&image.texture).fit_to_exact_size(size * zoom / 100.0)); + }); + }); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, "−", 32.0).clicked() { + fields.insert("__zoom".into(), json!((zoom / 2.0).max(0.1))); + } + ui.label(format!("{zoom:.1}%")); + if widgets::secondary_button(ui, "+", 32.0).clicked() { + fields.insert("__zoom".into(), json!((zoom * 2.0).min(800.0))); + } + }); +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/ui-egui/src/smart_filter_dialog/tests.rs b/crates/ui-egui/src/smart_filter_dialog/tests.rs new file mode 100644 index 0000000000..b3fb099d60 --- /dev/null +++ b/crates/ui-egui/src/smart_filter_dialog/tests.rs @@ -0,0 +1,297 @@ +use super::*; +use photocraft_doc::{SmartFilter, SmartObject}; + +/// A document reopened with the same id is a different allocation, so a dialog opened on the old one cannot edit it. +#[test] +fn a_same_id_reopened_document_rejects_the_previous_dialog() { + let (mut app, layer) = app(8, "rgb"); + let saved = app.session.active().unwrap().doc.as_ref().clone(); + app.session.close(0).unwrap(); + app.session.add_document(saved.clone(), None); + let id = open(&mut app, layer, 0).unwrap(); + let old = app.session.active().unwrap().doc.clone(); + app.session.close(0).unwrap(); + app.session.add_document(saved, None); + assert_eq!(app.session.active().unwrap().revision, 1); + assert_eq!(old.id, app.session.active().unwrap().doc.id); + let before = app.session.active().unwrap().doc.clone(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); +} + +/// An 8×8 image still gets the full-height preview area instead of collapsing it. +#[test] +fn tiny_images_keep_a_usable_preview_viewport() { + let (mut app, layer) = app(8, "rgb"); + let id = open(&mut app, layer, 0).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("__imageSize".into(), json!([8, 8])); + fields.insert("__zoom".into(), json!(100)); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 480.0))), ..Default::default() }; + ctx.run_ui(input, |ui| { + let start = ui.cursor().top(); + super::viewport(&mut app, ui, &mut fields, false); + assert!(ui.cursor().top() - start >= 220.0, "tiny image must not collapse the preview"); + }) + .textures_delta + .clear(); +} + +/// Run `body` for one frame and return every string it painted. +fn painted_text(app: &mut PhotocraftApp, fields: &mut Map) -> Vec { + /// Gather the text of `shape`, descending into nested shape groups. + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.job.text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, Default::default()); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(480.0, 640.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| super::body(app, ui, fields)); + output.textures_delta.clear(); + let mut painted = Vec::new(); + output.shapes.iter().for_each(|s| collect(&s.shape, &mut painted)); + painted +} + +/// Of all the blend modes, only Dissolve shows the approximation note beside the Mode control. +#[test] +fn only_dissolve_shows_the_approximation_note_near_the_mode_control() { + let note = "Dissolve is approximated: it blends without Photoshop's dither."; + let (mut app, layer) = app(8, "rgb"); + let id = open(&mut app, layer, 0).unwrap(); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + for blend in BlendMode::LAYER_MODES { + fields.insert("blend".into(), json!(blend.label())); + let painted = painted_text(&mut app, &mut fields); + assert!(painted.iter().any(|s| s == "Mode"), "the Mode control paints for {}", blend.label()); + assert_eq!(painted.iter().any(|s| s == note), blend == BlendMode::Dissolve, "note for {}", blend.label()); + } + assert_eq!(mode_note(None), None); + assert_eq!(mode_note(Some("Normal")), None); + assert_eq!(mode_note(Some("Dissolve")), Some(note)); +} + +/// A document with one Smart Object holding Box Blur and Smart Sharpen, wrapped in an app that renders on the calling thread. +pub(crate) fn app(depth: u32, mode: &str) -> (PhotocraftApp, LayerId) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "mode": mode})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Smart Object"})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#305080"})).unwrap(); + s.execute("select.rect", json!({"x":0,"y":0,"width":32,"height":48})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#e09040"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("filter.blur.boxBlur", json!({"radius": 4})).unwrap(); + s.execute("filter.sharpen.smartSharpen", json!({"amount": 125, "radius": 2})).unwrap(); + let mut app = PhotocraftApp::new(s, Default::default()); + app.background_jobs = false; + (app, layer) +} + +/// The Smart Object content of `layer` in the active document. +pub(crate) fn smart(app: &PhotocraftApp, layer: LayerId) -> &SmartObject { + let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else { panic!("smart") }; + sm +} + +/// A copy of the Smart Object's filters, for comparing before and after an edit. +fn filters(app: &PhotocraftApp, layer: LayerId) -> Vec { + smart(app, layer).smart_filters.clone() +} + +/// Blending edits one filter in a single history step, opacity is entered as a percentage, and undo/redo restore it. +#[test] +fn blending_edits_one_filter_with_percent_opacity_and_undo_redo() { + for depth in [8, 16, 32] { + for mode in ["rgb", "gray"] { + let (mut app, layer) = app(depth, mode); + let original = filters(&app, layer); + let source = photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(); + for opacity in [0.0, 1.0, 50.0, 100.0] { + for blend in ["Normal", "Multiply", "Screen", "Luminosity"] { + let steps = app.session.active().unwrap().history.past_len(); + let id = open(&mut app, layer, 0).unwrap(); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("opacity".into(), json!(opacity)); + f.insert("blend".into(), json!(blend)); + let fields = f.clone(); + let (cmd, params) = edit_command(&app, &fields).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let expected = crate::filter_dialog::preview_document(&before, Some(layer), &cmd, ¶ms, 1).unwrap(); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(&mut app, id).unwrap(); + let changed = filters(&app, layer); + assert!((changed[0].opacity - opacity as f32 / 100.0).abs() < 1e-6); + assert_eq!(changed[0].blend.label(), blend); + assert_eq!(serde_json::to_value(&changed[1]).unwrap(), serde_json::to_value(&original[1]).unwrap()); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(), source); + assert_eq!(app.session.active().unwrap().doc.layer(layer).unwrap().blend, BlendMode::Normal); + assert_eq!( + smart(&app, layer).cache.as_ref().unwrap().read_region(before.bounds()), + expected.layer(layer).unwrap().surface().unwrap().read_region(expected.bounds()) + ); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(filters(&app, layer)).unwrap(), serde_json::to_value(&original).unwrap()); + app.run("edit.redo", json!({})).unwrap(); + let id = open(&mut app, layer, 0).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["opacity"].as_f64().unwrap(), opacity); + crate::dialogs::cancel(&mut app, id).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + } + } + } + } +} + +/// The preview, switching it off and on, and Cancel never change the document or its history. +#[test] +fn preview_toggle_and_cancel_leave_document_and_history_untouched() { + let (mut app, layer) = app(8, "rgb"); + let st = app.session.active().unwrap(); + let before = st.doc.clone(); + let steps = st.history.past_len(); + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(1)); + let ctx = egui::Context::default(); + let idx = app.session.active_index().unwrap(); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_some()); + assert_eq!(app.filter_preview.as_ref().unwrap().key.params["opacity"], json!(0.01)); + app.ui.dialog_mut(id).unwrap().fields.insert("__preview".into(), json!(false)); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_none()); + app.ui.dialog_mut(id).unwrap().fields.insert("__preview".into(), json!(true)); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_some()); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.filter_preview.is_none()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +/// A dialog whose document or filter has since changed, or been switched away from, refuses to confirm. +#[test] +fn stale_filter_dialogs_cannot_edit_a_switched_or_changed_target() { + for command in ["file.new", "edit.undo", "layer.smartFilter.delete", "layer.smartFilter.move"] { + let (mut app, layer) = app(8, "rgb"); + let id = open(&mut app, layer, 0).unwrap(); + let params = match command { + "file.new" => json!({"width": 16,"height":16}), + "layer.smartFilter.move" => json!({"layer":layer.0,"index":0,"to":1}), + _ => json!({"layer":layer.0,"index":0}), + }; + app.run(command, params).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + assert!(crate::dialogs::confirm(&mut app, id).is_err(), "{command}"); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } +} + +/// A reduced-size preview scales the filters' pixel parameters and the shared mask, and still contains the same number of filters. +#[test] +fn reduced_preview_scales_placement_stack_and_mask_without_adding_filters() { + let (mut app, layer) = app(16, "rgb"); + // A half-visible shared mask, different from the layer mask. + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!() }; + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.fill_rect(photocraft_geom::Rect::new(32, 0, 64, 48), &[0.0]); + sm.filter_mask = Some(mask); + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(50)); + let (command, params) = edit_command(&app, &app.ui.dialogs[0].fields).unwrap(); + let original = app.session.active().unwrap().doc.clone(); + let preview = crate::filter_dialog::preview_document(&original, Some(layer), &command, ¶ms, 2).unwrap(); + assert_eq!(preview.size.width, 32); + let LayerContent::Smart(sm) = &preview.layer(layer).unwrap().content else { panic!() }; + assert_eq!(sm.smart_filters.len(), 2); + assert_eq!(sm.smart_filters[0].params["radius"], json!(2.0)); + assert_eq!(sm.smart_filters[1].params["radius"], json!(1.0)); + assert_eq!(sm.filter_mask.as_ref().unwrap().surface.pixel(17, 0)[0], 0.0); + assert!(sm.cache.as_ref().unwrap().content_bounds().x1 <= 32); + assert!(Arc::ptr_eq(&original, &app.session.active().unwrap().doc)); +} + +/// An edited blend and an Invert recorded earlier survive a save and reload of the native format. +#[test] +fn native_save_preserves_edited_blending_and_existing_invert() { + let (mut app, layer) = app(8, "rgb"); + let ctx = egui::Context::default(); + crate::menus::invoke(&mut app, &ctx, "image.adjustments.invert", json!({})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters.last().unwrap().command, "image.adjustments.invert"); + let id = open(&mut app, layer, 2).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(1)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let doc = &app.session.active().unwrap().doc; + let loaded = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(doc, &Default::default()).unwrap()).unwrap(); + let LayerContent::Smart(sm) = &loaded.layers.last().unwrap().content else { panic!() }; + assert_eq!(serde_json::to_value(&sm.smart_filters).unwrap(), serde_json::to_value(filters(&app, layer)).unwrap()); +} + +/// An opacity outside 0–100% or not a number fails the dialog and leaves the document alone. +#[test] +fn invalid_opacity_fails_without_an_edit() { + let (mut app, layer) = app(8, "rgb"); + for opacity in [json!(-1), json!(101), json!("x"), Value::Null] { + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), opacity); + let before = app.session.active().unwrap().doc.clone(); + assert!(crate::dialogs::confirm(&mut app, id).is_err()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } +} + +/// Blending edits the filter the dialog opened on, whatever layer is selected and whether or not a layer mask is targeted. +#[test] +fn blending_does_not_follow_layer_selection_or_the_layer_mask_target() { + let (mut app, layer) = app(8, "rgb"); + app.run("layer.new.layer", json!({"name":"Selected pixel layer"})).unwrap(); + let selected = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerMask.revealAll", json!({"layer":selected.0})).unwrap(); + app.ui.mask_target = true; + let doc = app.session.active().unwrap().doc.clone(); + let mask = doc.layer(selected).unwrap().mask.as_ref().unwrap().surface.clone(); + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(25)); + let idx = app.session.active_index().unwrap(); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &Default::default()).is_some()); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[0].opacity, 0.25); + assert_eq!(app.session.active().unwrap().active_layer, Some(selected)); + assert_eq!( + crate::surface_fingerprint(&app.session.active().unwrap().doc.layer(selected).unwrap().mask.as_ref().unwrap().surface), + crate::surface_fingerprint(&mask) + ); +} + +/// Blending an Invert smart filter keeps the translucent pixels' alpha at 8, 16 and 32 bits. +#[test] +fn invert_blending_keeps_transparency_at_every_depth() { + for depth in [8, 16, 32] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width":8,"height":8,"depth":depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("synthetic translucent pixels", |doc, active| { + let bounds = doc.bounds(); + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.2, 0.4, 0.6, 0.5]); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + let before = s.active().unwrap().doc.layer(layer).unwrap().surface().unwrap().pixel(3, 3); + s.execute("image.adjustments.invert", json!({})).unwrap(); + let mut app = PhotocraftApp::new(s, Default::default()); + app.background_jobs = false; + let id = open(&mut app, layer, 0).unwrap(); + app.ui.dialog_mut(id).unwrap().fields.insert("opacity".into(), json!(1)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(smart(&app, layer).cache.as_ref().unwrap().pixel(3, 3)[3], before[3]); + } +} diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs index 9db54bbe1e..d05e02e014 100644 --- a/crates/ui-egui/src/smart_filter_rows_tests.rs +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -229,3 +229,91 @@ fn clicking_the_smart_object_row_deselects_its_filter() { click_at(&mut h, trash); assert_eq!(filters(&h, a).len(), 2, "the filters stay"); } + +/// #4751: Delete or Backspace removes the selected filter, as the Delete button does, not the +/// smart object; on the Smart Filters row it clears them all. +#[test] +fn the_delete_key_removes_the_selected_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(filters(&h, a), ["filter.other.offset"]); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); + let row = at(&h, "Smart Filters"); + click_at(&mut h, row); + h.key_press(egui::Key::Backspace); + h.run_steps(4); + assert!(filters(&h, a).is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); +} + +/// A text field keeps its Delete: renaming the smart object leaves the selected filter alone. +#[test] +fn the_delete_key_in_a_text_field_leaves_the_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + let ctx = h.ctx.clone(); + crate::layer_row_ui::start_rename(&ctx, a, "Smart A"); + h.run_steps(4); + assert!(selected(&h, "Gaussian Blur"), "the filter is still selected while renaming"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(filters(&h, a).len(), 2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4); +} + +/// Quick Mask keeps Delete for Edit › Clear on the mask, even with a filter's row selected. +#[test] +fn the_delete_key_in_quick_mask_clears_the_mask_not_the_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + // No selection: Quick Mask alone must keep the key, not a marquee's Edit › Clear. + h.state_mut().run("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + assert!(h.state().session.active().unwrap().doc.selection.is_none()); + h.run_steps(4); + assert!(selected(&h, "Gaussian Blur"), "the filter is still selected in Quick Mask"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(filters(&h, a).len(), 2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4); +} + +/// A filter whose row isn't drawn (here the panel is filtered by name) is no longer selected: +/// Delete then deletes the active layer as usual, never a filter nobody sees highlighted. +#[test] +fn a_hidden_filter_row_is_deselected() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + h.state_mut().ui.layer_filter_by = crate::layer_filter_ui::FilterBy::Name; + h.state_mut().ui.layer_filter_name = "Pixels".into(); + h.run_steps(4); + // Shown again, the row is no longer highlighted: the selection went, it wasn't just hidden. + h.state_mut().ui.layer_filter_name.clear(); + h.run_steps(4); + assert!(!selected(&h, "Gaussian Blur")); + h.key_press(egui::Key::Delete); + h.run_steps(4); + let st = h.state().session.active().unwrap(); + assert!(st.doc.layer(LayerId(a)).is_none(), "Delete reached Smart A itself"); +} + +/// With the Layers panel closed the selected row isn't on screen either, so Delete deletes the +/// active layer as usual. +#[test] +fn with_the_layers_panel_closed_delete_leaves_the_filter() { + let (mut h, a, _, _) = panel(); + let blur = at(&h, "Gaussian Blur"); + click_at(&mut h, blur); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "window.toggle.layers", json!({})).unwrap(); + h.run_steps(4); + assert!(h.query_all_by_label("Gaussian Blur").next().is_none(), "the panel is closed"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert!(h.state().session.active().unwrap().doc.layer(LayerId(a)).is_none(), "Delete reached Smart A itself"); +} diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 2acc77ceaf..578130d5b5 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -3,10 +3,11 @@ //! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271; //! drag it up or down its own stack to reorder it). -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject}; use serde_json::{Value, json}; +use crate::state::LayerThumbnailSize; use crate::theme::Tokens; use crate::{PhotocraftApp, icons}; @@ -60,7 +61,7 @@ pub fn thumb_badge(ui: &egui::Ui, l: &Layer, thumb: Rect) { /// The display name of a smart filter (its command's label without the ellipsis). A Photoshop /// filter PhotoCraft doesn't implement shows its own name and is marked as kept as is. -fn filter_label(f: &photocraft_doc::SmartFilter) -> String { +pub(crate) fn filter_label(f: &photocraft_doc::SmartFilter) -> String { let command = f.command.as_str(); if command == photocraft_engine::smart_cmds::UNSUPPORTED_FILTER { let name = f.params.get("name").and_then(Value::as_str).unwrap_or("Smart filter"); @@ -70,27 +71,48 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String { .map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string()) } +/// Height of a Smart Filter or effect sub-row. As in Photoshop, these stay compact at every +/// Layers panel thumbnail size; only the layer row itself grows with its thumbnails. +pub(crate) const SUB_ROW_H: f32 = 22.0; + +/// The Smart Filters header row: (row height, shared-mask thumbnail side). The mask follows the +/// Layers panel thumbnail size (hidden at None) and the row grows just enough to hold it. +pub(crate) fn smart_filters_header_geometry(size: LayerThumbnailSize, pro: bool) -> (f32, f32) { + let (thumb, _) = crate::layers_panel_ui::thumbnail_geometry(size, pro); + if thumb == 0.0 { (SUB_ROW_H, 0.0) } else { ((thumb + 6.0).max(SUB_ROW_H), thumb) } +} + /// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart /// layer. Edits are queued in `actions` as engine commands. -pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { +pub fn has_filters(l: &Layer) -> bool { + matches!(&l.content, LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) +} + +/// The two rings in the layer row indicate an attached Smart Filters stack. +pub fn paint_badge(painter: &egui::Painter, center: egui::Pos2, color: Color32) { + for dx in [-2.5, 2.5] { + painter.circle_stroke(center + vec2(dx, 0.0), 3.5, Stroke::new(1.0, color)); + } +} + +/// Paint the Smart Filters header and one row per filter of Smart Object `l` (top filter first), with their drag, drop and click handling; actions go to `actions`. +pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l: &Layer, depth: usize, actions: &mut Vec<(String, Value)>) { let LayerContent::Smart(sm) = &l.content else { return }; if sm.smart_filters.is_empty() { return; } let t = Tokens::get(ui.ctx()); - let indent = 30.0 + depth as f32 * 14.0 + 34.0; + let (header_height, mask_size) = smart_filters_header_geometry(app.ui.layers_panel_options.thumbnail_size, t.pro); + let indent = 30.0 + crate::layer_row_ui::indent(depth, ui.available_width(), 130.0); let Some(st) = app.session.active() else { return }; - let (active, doc) = (st.active_layer == Some(l.id), st.doc.id); - let picked = ui.ctx().data(|d| d.get_temp::(selected_key(doc))); + let active = st.active_layer == Some(l.id); + let picked = ui.ctx().data(|d| d.get_temp::(selected_key(doc.id))); let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp::(drag_key(), (l.id.0, index))); - } + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), if index.is_none() { header_height } else { SUB_ROW_H }), Sense::click_and_drag()); if let Some((to, action)) = reorder_drop(ui, l, sm.smart_filters.len(), index, rect) { // The moved filter ends up selected, with its smart object active, as after a click. - ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc), (l.id.0, Some(to)))); + ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc.id), (l.id.0, Some(to)))); actions.push(action); if !active { actions.push(("layer.select".into(), json!({"layer": l.id.0}))); @@ -100,50 +122,109 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: let selected = active && picked == Some((l.id.0, index)); if selected { ui.painter().rect_filled(rect, 0.0, if t.pro { t.row_selected } else { t.hover }); - } else if resp.hovered() { + } else if resp.contains_pointer() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } if t.pro { ui.painter().line_segment([pos2(rect.left() + 30.0, rect.top()), pos2(rect.left() + 30.0, rect.bottom())], Stroke::new(1.0, t.separator)); } let (name, on) = match index { - None => ("Smart Filters".to_string(), sm.filters_enabled), + None => (tl!("Smart Filters").to_string(), sm.filters_enabled), Some(i) => (filter_label(&sm.smart_filters[i]), sm.smart_filters[i].visible && sm.filters_enabled), }; - let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 9.0), vec2(18.0, 18.0)); + let eye = Rect::from_center_size(pos2(rect.left() + indent + if index.is_none() { 9.0 } else { 41.0 }, rect.center().y), vec2(18.0, 18.0)); let eye_resp = ui.interact(eye, ui.id().with(("sf-eye", l.id.0, index)), Sense::click()); let shown = match index { None => sm.filters_enabled, Some(i) => sm.smart_filters[i].visible, }; if shown { - icons::paint(ui, eye, "eye", 12.0, t.icon); + icons::paint(ui, eye, "eye", 12.0, if on { t.icon } else { t.text_faint }); } + eye_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, shown, format!("{} {name}", tl!("Visibility")))); if eye_resp.clicked() { actions.push(match index { None => ("layer.smartFilter.disableSmartFilters".into(), json!({"layer": l.id.0})), Some(i) => ("layer.smartFilter.setVisible".into(), json!({"layer": l.id.0, "index": i})), }); } - let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; - if index.is_none() { - let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); - ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); + // The shared mask scales with the thumbnail setting (hidden at None); the header name + // follows it, while filter names stay aligned past their eyes. + let mask_left = rect.left() + indent + 28.0; + let x = match index { + None if mask_size > 0.0 => mask_left + mask_size + 8.0, + None => mask_left, + Some(_) => rect.left() + indent + 60.0, + }; + if index.is_none() && mask_size > 0.0 { + let sw = Rect::from_min_size(pos2(mask_left, rect.center().y - mask_size / 2.0), vec2(mask_size, mask_size)); + if let Some(mask) = &sm.filter_mask { + let tex = app.mask_thumb_kind(ui.ctx(), doc, l.id, mask, crate::mask_thumbs_ui::THUMB_SMART_MASK); + ui.painter().image(tex, sw, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if !mask.enabled { + ui.painter().line_segment([sw.left_top(), sw.right_bottom()], Stroke::new(1.0, t.danger)); + ui.painter().line_segment([sw.right_top(), sw.left_bottom()], Stroke::new(1.0, t.danger)); + } + } else { + ui.painter().rect_filled(sw, 0.0, Color32::WHITE); + } ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, &name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); - if resp.clicked() { - ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc), (l.id.0, index))); + let blend = index.map(|_| Rect::from_center_size(pos2(rect.right() - crate::layer_row_ui::RIGHT_PAD - 9.0, rect.center().y), vec2(18.0, 18.0))); + let right = blend.map_or(rect.right() - crate::layer_row_ui::RIGHT_PAD, |r| r.left() - 4.0); + crate::layer_row_ui::label( + ui.painter(), + x, + rect.center().y, + right, + &name, + egui::FontId::proportional(11.5), + if on { t.text_dim } else { t.text_faint }, + ); + // A filter's name is its own target, apart from the Blending Options icon (#3283): it + // selects, drags (#4271) and opens the filter's editor on a double-click. The row around + // it does the same, but only the name carries the row's label and selected state. + let name_resp = index.map(|i| { + let name_rect = Rect::from_min_max(pos2(x.min(right), rect.top()), pos2(right, rect.bottom())); + ui.interact(name_rect, ui.id().with(("sf-name", l.id.0, i)), Sense::click_and_drag()) + }); + match &name_resp { + Some(r) => r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)), + None => resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)), + } + let hit = |f: fn(&egui::Response) -> bool| f(&resp) || name_resp.as_ref().is_some_and(f); + if hit(egui::Response::drag_started) { + ui.ctx().data_mut(|d| d.insert_temp::(drag_key(), (l.id.0, index))); + } + if hit(egui::Response::clicked) { + ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc.id), (l.id.0, index))); if !active { actions.push(("layer.select".into(), json!({"layer": l.id.0}))); } } if let Some(i) = index - && resp.double_clicked() + && hit(egui::Response::double_clicked) { open_editor(app, ui.ctx(), l.id.0, sm, i); } + if let (Some(i), Some(r)) = (index, blend) { + let blend_resp = ui.interact(r, ui.id().with(("sf-blend", l.id.0, i)), Sense::click()); + icons::paint(ui, r, "sliders-horizontal", 13.0, if blend_resp.hovered() { t.text } else { t.icon }); + let label = crate::i18n::fmt(tl!("Blending Options ({filter})"), &[("filter", &name)]); + blend_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + if blend_resp.double_clicked() + && let Err(e) = crate::smart_filter_dialog::open(app, l.id, i) + { + crate::notices::error(app, e); + } + blend_resp.on_hover_text(label); + } + } + // Every row of `l` is drawn, so a filter of it selected (now, after this pass's clicks) is on + // screen: see [`forget_hidden`]. + if ui.ctx().data(|d| d.get_temp::(selected_key(doc.id))).is_some_and(|(layer, _)| layer == l.id.0) { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx().data_mut(|d| d.insert_temp(drawn_key(doc.id), pass)); } } @@ -151,6 +232,7 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: /// Filters row, all of them). Also what a click selects. pub(crate) type FilterDrag = (u64, Option); +/// Memory key of the smart filter row being dragged. pub(crate) fn drag_key() -> egui::Id { egui::Id::new("smart-filter-drag") } @@ -160,12 +242,22 @@ fn selected_key(doc: photocraft_doc::DocId) -> egui::Id { egui::Id::new(("smart-filter-selected", doc)) } -/// The selected smart filter row, while its smart object is active and the filter still exists. +/// The pass in which the selected smart filter row of a document was last drawn. +fn drawn_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("smart-filter-selected-drawn", doc)) +} + +/// The selected smart filter row, while its smart object is active, the filter still exists and +/// its row is on screen (drawn this pass or the last: the Layers panel may be closed). pub(crate) fn selected(ctx: &egui::Context, doc: &Document, active: Option) -> Option { let (layer, filter) = ctx.data(|d| d.get_temp::(selected_key(doc.id)))?; if active != Some(LayerId(layer)) { return None; } + let drawn = ctx.data(|d| d.get_temp::(drawn_key(doc.id)))?; + if drawn + 1 < ctx.cumulative_pass_nr() { + return None; + } let LayerContent::Smart(sm) = &doc.layer(LayerId(layer))?.content else { return None }; let exists = match filter { Some(i) => i < sm.smart_filters.len(), @@ -180,6 +272,17 @@ pub(crate) fn deselect(ctx: &egui::Context, doc: photocraft_doc::DocId) { ctx.data_mut(|d| d.remove::(selected_key(doc))); } +/// Forgets the selected smart filter row when the Layers panel didn't draw it this pass (its smart +/// object is in a closed group, filtered out or isolated away): Delete must never remove a filter +/// nobody sees highlighted. +pub(crate) fn forget_hidden(ctx: &egui::Context, doc: photocraft_doc::DocId) { + let picked = ctx.data(|d| d.get_temp::(selected_key(doc))); + let drawn = ctx.data(|d| d.get_temp::(drawn_key(doc))); + if picked.is_some() && drawn != Some(ctx.cumulative_pass_nr()) { + deselect(ctx, doc); + } +} + /// While smart filters are dragged: their name by the pointer, and the copy cursor while ⌥ is held. pub(crate) fn drag_feedback(ctx: &egui::Context, doc: &Document) { let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; @@ -275,6 +378,36 @@ pub(crate) fn trash_action((layer, filter): FilterDrag) -> (String, Value) { } } +/// Delete or Backspace with no modifiers while a filter's row is selected removes that filter (the +/// Smart Filters row: all of them), as the panel's Delete button does (#4751), instead of +/// deleting the smart object or reaching Edit › Clear. +pub(crate) fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use crate::shortcut_dispatch::Focus; + // Text fields and widgets that delete with these keys themselves keep them (#1534). + if app.ui.text_edit.is_some() || matches!(Focus::of(ctx), Focus::Text | Focus::DeletingWidget) { + return false; + } + let Some(st) = app.session.active() else { return false }; + let Some(picked) = selected(ctx, &st.doc, st.active_layer) else { return false }; + let doc = st.doc.id; + // Only where the key would delete the smart object: Quick Mask, a single channel, a targeted + // mask or a selection keep their own Delete. + if crate::shortcut_dispatch::delete_key_command(app, "edit.clear".into()) != "layer.delete" { + return false; + } + let mods = ctx.input(|i| i.modifiers); + if !mods.is_none() || !ctx.input_mut(|i| i.consume_key(mods, egui::Key::Delete) || i.consume_key(mods, egui::Key::Backspace)) { + return false; + } + let (command, params) = trash_action(picked); + deselect(ctx, doc); + if let Err(e) = app.run(&command, params) { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + /// Re-opens a smart filter's dialog with its recorded parameters; OK runs /// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`). pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) { @@ -295,35 +428,37 @@ pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: } return; } - // An adjustment recorded on the smart object (Brightness/Contrast, Levels…) re-opens its own - // Image › Adjustments editor, like the filters above. - if crate::adjust_dialog::has_dialog(&f.command) { - if let Some(id) = crate::adjust_dialog::open(app, &f.command) { - edit_filter(app, id, layer, index, &f.params); + // And Liquify, which re-opens with the filter's recorded strokes. + if f.command == photocraft_engine::distort_cmds::LIQUIFY { + if let Err(e) = crate::liquify_ui::open_smart_filter(app, ctx, photocraft_doc::LayerId(layer), index) { + app.ui.status = e; + app.ui.status_error = true; } return; } - if !crate::filter_dialog::has_dialog(&f.command) { + let target = match crate::smart_filter_dialog::target(app, photocraft_doc::LayerId(layer), index) { + Ok(target) => target, + Err(e) => { + crate::notices::error(app, e); + return; + } + }; + let Some(id) = crate::adjust_dialog::open(app, &f.command).or_else(|| { + let id = crate::filter_dialog::has_dialog(&f.command).then(|| crate::filter_dialog::open(app, &f.command)).flatten()?; + // The filter dialog opens on the user's last-used values; a recorded filter that lacks a + // key must show (and save) the schema default instead. + let mut fields = app.ui.dialog_mut(id)?.fields.clone(); + crate::filter_dialog::reset_fields(app, &mut fields); + app.ui.dialog_mut(id)?.fields = fields; + Some(id) + }) else { return; - } - let Some(id) = crate::filter_dialog::open(app, &f.command) else { return }; - edit_filter(app, id, layer, index, &f.params); -} - -/// Hands the open dialog `id` the smart filter's recorded `params` and makes OK update that -/// filter (`layer.smartFilter.setParams`, see [`confirm_command`]) instead of adding another. -fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, params: &Value) { + }; if let Some(d) = app.ui.dialog_mut(id) { - // A filter's live preview runs `layer.smartFilter.setParams` on the proxy, as OK does - // (`canvas::ensure_filter_preview`, #6324). An adjustment's previews on the layer and - // would stack a second copy of it on the smart object. - if d.fields.contains_key("__adjust") { - d.fields.remove("__preview"); - } - d.fields.insert("__smartFilter".into(), json!({"layer": layer, "index": index})); - if let Value::Object(p) = params { + d.fields.insert("__smartFilter".into(), target); + if let Value::Object(p) = &f.params { for (k, v) in p { - if d.fields.contains_key(k) { + if !k.starts_with("__") { d.fields.insert(k.clone(), v.clone()); } } @@ -331,17 +466,70 @@ fn edit_filter(app: &mut PhotocraftApp, id: u64, layer: u64, index: usize, param } } -/// The command a filter dialog's live preview runs ([`confirm_command`]'s): `layer.smartFilter.setParams` -/// while it edits a smart filter (#6324), else `command`. +/// The command a filter dialog's live preview runs ([`confirm_command`]'s): the Smart Filter blending +/// command for its Blending Options dialog, `layer.smartFilter.setParams` while it edits a smart filter +/// (#6324), else `command`. pub fn preview_command<'a>(fields: &serde_json::Map, command: &'a str) -> &'a str { - if fields.contains_key("__smartFilter") { "layer.smartFilter.setParams" } else { command } + if crate::smart_filter_dialog::owns(fields) { + crate::smart_filter_dialog::COMMAND + } else if fields.contains_key("__smartFilter") { + "layer.smartFilter.setParams" + } else { + command + } } /// The command a confirmed filter dialog runs: smart-filter edits update the recorded filter. -pub fn confirm_command(fields: &serde_json::Map, command: String, params: Value) -> (String, Value) { - match fields.get("__smartFilter") { - Some(sf) => ("layer.smartFilter.setParams".into(), json!({"layer": sf["layer"], "index": sf["index"], "params": params})), - None => (command, params), +pub fn confirm_command(app: &PhotocraftApp, fields: &serde_json::Map, command: String, params: Value) -> Result<(String, Value), String> { + // One builder for the preview and for OK, so the two cannot differ. + if fields.contains_key("__smartFilter") { + return crate::smart_filter_dialog::edit_command(app, fields); + } + Ok((command, params)) +} + +#[cfg(test)] +mod editor_tests; + +#[cfg(test)] +mod layer_thumbnail_scaling_tests { + use super::*; + use egui_kittest::{Harness, kittest::Queryable}; + + #[test] + fn sub_rows_stay_compact_while_the_smart_filter_mask_follows_thumbnail_setting() { + for setting in [LayerThumbnailSize::None, LayerThumbnailSize::Small, LayerThumbnailSize::Medium, LayerThumbnailSize::Large] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 50, "height": 38})).unwrap(); + session.execute("edit.fill", json!({"color": "#8A42B3"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + session.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + session.execute("filter.sharpen.unsharpMask", json!({"amount": 80, "radius": 1, "threshold": 0})).unwrap(); + session.execute("layer.layerStyle.dropShadow", json!({"size": 2})).unwrap(); + assert!(matches!(session.active().unwrap().doc.layers.last().unwrap().content, LayerContent::Smart(_))); + let mut h = Harness::builder().with_size(vec2(1440.0, 1400.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(session, crate::Services::default()) + }); + h.state_mut().ui.layers_panel_options.thumbnail_size = setting; + h.run_steps(8); + // Filter and effect rows keep Photoshop's compact height at every thumbnail size. + for label in ["Gaussian Blur", "Unsharp Mask", "Effects", "Drop Shadow"] { + let rect = h.get_by_label(label).rect(); + assert!((rect.height() - SUB_ROW_H).abs() < 1.0, "{setting:?}: {label} was {rect:?}, expected {SUB_ROW_H}"); + } + // Only the Smart Filters header grows, just enough for its shared-mask thumbnail. + let (header, mask) = smart_filters_header_geometry(setting, true); + let rect = h.get_by_label("Smart Filters").rect(); + assert!((rect.height() - header).abs() < 1.0, "{setting:?}: Smart Filters was {rect:?}, expected {header}"); + assert!(mask <= header - 6.0 || mask == 0.0, "{setting:?}: a {mask} mask must fit a {header} row"); + assert_eq!(mask == 0.0, setting == LayerThumbnailSize::None, "{setting:?}: the mask hides only at None"); + if let Some(dir) = std::env::var_os("PHOTOCRAFT_LAYERS_SNAPSHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("layers-sub-rows-{setting:?}.png"))).unwrap(); + } + } } } diff --git a/crates/ui-egui/src/smart_ui/editor_tests.rs b/crates/ui-egui/src/smart_ui/editor_tests.rs new file mode 100644 index 0000000000..6e0b9d14f8 --- /dev/null +++ b/crates/ui-egui/src/smart_ui/editor_tests.rs @@ -0,0 +1,187 @@ +use egui::{Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; + +use crate::{ + PhotocraftApp, + smart_filter_dialog::tests::{app, smart}, +}; + +/// The app with a recorded Smart Object, in a `width`-point dock and the given theme, ready to click. +fn harness(width: f32, scale: f32, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let (app, _) = app(8, "rgb"); + let mut h = + Harness::builder().with_size(vec2(1200.0, 850.0)).with_pixels_per_point(scale).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + app + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new( + "ui.set", + json!({"theme":theme.id(),"dockWidth":width,"dock":{"collapsed":["color","properties","history","navigator"]}}), + ); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(5); + h +} + +/// Click `at` with the primary button, hovering first so the row reacts. +fn click(h: &mut Harness<'_, PhotocraftApp>, at: egui::Pos2) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +/// A filter row's name and blend icon are separate double-click targets, side by side without overlap, in every theme and dock width. +#[test] +fn names_and_blend_icons_have_separate_double_click_targets_and_no_overlap() { + for theme in crate::theme::ThemeKind::ALL { + for width in [250.0, 420.0] { + let mut h = harness(width, 1.0, theme); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let rows = crate::layer_row_ui::recorded(&h.ctx); + let row = rows.iter().find(|r| r.layer == layer.0).unwrap(); + assert!(row.indicators.iter().any(|(k, _)| *k == crate::layer_row_ui::Indicator::SmartFilters)); + let blend = h.get_by_label("Blending Options (Box Blur)").rect(); + let edit = h.get_by_label("Box Blur").rect(); + assert!(edit.right() <= blend.left(), "{width} {theme:?}: {edit:?} {blend:?}"); + click(&mut h, blend.center()); + assert!(h.state().ui.dialogs.is_empty(), "single click does not open blending"); + click(&mut h, blend.center()); + assert!(crate::smart_filter_dialog::owns(&h.state().ui.dialogs.last().unwrap().fields)); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__smartFilter"]["index"], json!(0)); + let id = h.state().ui.dialogs.last().unwrap().id; + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + // Start a new gesture after egui's triple-click interval, as native users do. + for _ in 0..40 { + h.step(); + } + click(&mut h, edit.center()); + click(&mut h, edit.center()); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__command"], json!("filter.blur.boxBlur")); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["radius"].as_f64(), Some(4.0)); + } + } +} + +/// The effects disclosure hides the effects and Smart Filters stacks together, as a view change only. +#[test] +fn disclosure_hides_both_stacks_without_changing_pixels_history_or_selection() { + let mut h = harness(290.0, 1.5, Default::default()); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("layer.layerStyle.dropShadow", json!({"layer":layer.0})).unwrap(); + h.state_mut().run("layer.new.layer", json!({"name":"Selected elsewhere"})).unwrap(); + h.run_steps(5); + let before = h.state().session.active().unwrap().doc.clone(); + let revision = h.state().session.active().unwrap().revision; + let steps = h.state().session.active().unwrap().history.past_len(); + let active = h.state().session.active().unwrap().active_layer; + let at = h.get_by_label("Collapse effects Smart Object").rect().center(); + click(&mut h, at); + assert!(h.state().session.active().unwrap().fx_collapsed.contains(&layer)); + assert!(h.query_by_label("Blending Options (Box Blur)").is_none()); + assert!(h.query_by_label("Drop Shadow").is_none()); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().revision, revision); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps); + assert_eq!(h.state().session.active().unwrap().active_layer, active); + let at = h.get_by_label("Expand effects Smart Object").rect().center(); + click(&mut h, at); + assert!(h.query_by_label("Blending Options (Box Blur)").is_some()); +} + +/// An adjustment on a Smart Object reopens its editor with the stored values and OK updates it in one step; Invert has no editor. +#[test] +fn adjustment_editor_reopens_stored_parameters_and_updates_instead_of_appending() { + let (mut app, layer) = app(8, "rgb"); + app.run("image.adjustments.hueSaturation", json!({"hue":35,"saturation":12,"lightness":-7})).unwrap(); + let ctx = egui::Context::default(); + let sm = smart(&app, layer).clone(); + super::open_editor(&mut app, &ctx, layer.0, &sm, 2); + let id = app.ui.dialogs.last().unwrap().id; + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(fields["hue"], json!(35)); + assert!(crate::adjust_dialog::owns(fields)); + fields.insert("hue".into(), json!(75)); + let idx = app.session.active_index().unwrap(); + assert!(!crate::adjust_preview::on_layer(&mut app, idx)); + assert!(crate::canvas::ensure_filter_preview(&mut app, idx, &ctx).is_some()); + let steps = app.session.active().unwrap().history.past_len(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(smart(&app, layer).smart_filters.len(), 3); + assert_eq!(smart(&app, layer).smart_filters[2].params["hue"], json!(75)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(&app, layer).smart_filters[2].params["hue"], json!(35)); + app.run("image.adjustments.invert", json!({})).unwrap(); + let sm = smart(&app, layer).clone(); + super::open_editor(&mut app, &ctx, layer.0, &sm, 3); + assert!(app.ui.dialogs.is_empty(), "Invert has no parameter editor"); +} + +/// The Smart Filters mask and the layer mask of one layer are cached as separate thumbnails. +#[test] +fn smart_and_layer_masks_have_separate_thumbnail_cache_entries() { + let (mut app, layer) = app(8, "rgb"); + app.run("layer.layerMask.revealAll", json!({"layer":layer.0})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let mask = doc.layer(layer).unwrap().mask.as_ref().unwrap(); + let ctx = egui::Context::default(); + let first = app.mask_thumb(&ctx, &doc, layer, mask); + let mut other = mask.clone(); + other.surface.fill_rect(photocraft_geom::Rect::new(0, 0, 64, 48), &[0.0]); + let second = app.mask_thumb_kind(&ctx, &doc, layer, &other, crate::mask_thumbs_ui::THUMB_SMART_MASK); + assert_ne!(first, second); + assert_eq!(first, app.mask_thumb(&ctx, &doc, layer, mask)); +} + +/// ⌘I on a Smart Object adds the ordinary Invert as a smart filter, and undo/redo leave the source contents alone. +#[test] +fn ctrl_i_uses_existing_invert_command_and_undo_redo_keep_the_source() { + let mut h = harness(290.0, 1.0, Default::default()); + let layer = h.state().session.active().unwrap().active_layer.unwrap(); + let before = smart(h.state(), layer).clone(); + let source = photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(); + let steps = h.state().session.active().unwrap().history.past_len(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::I); + h.run_steps(4); + let sm = smart(h.state(), layer); + assert_eq!(sm.smart_filters.len(), 3); + assert_eq!(sm.smart_filters[2].command, "image.adjustments.invert"); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); + assert_ne!(sm.cache.as_ref().unwrap().pixel(8, 8), before.cache.as_ref().unwrap().pixel(8, 8)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(smart(h.state(), layer).smart_filters.len(), 2); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(smart(h.state(), layer).smart_filters.len(), 3); +} + +/// A recorded filter that lacks a parameter reopens with the schema default for it, not with the +/// value the user last used in that filter's dialog; the parameters it does hold still win. +#[test] +fn filter_editor_starts_missing_parameters_from_defaults_not_remembered_values() { + let (mut app, layer) = app(8, "rgb"); + let command = "filter.sharpen.smartSharpen"; + app.session.prefs.edit(|p| { + p.dialogs.insert(command.into(), json!({"reduceNoise": 60, "radius": 9})); + }); + let ctx = egui::Context::default(); + let mut sm = smart(&app, layer).clone(); + let index = sm.smart_filters.iter().position(|f| f.command == command).unwrap(); + sm.smart_filters[index].params = json!({"amount": 125, "radius": 2}); + + super::open_editor(&mut app, &ctx, layer.0, &sm, index); + let fields = &app.ui.dialogs.last().unwrap().fields; + assert!(fields.contains_key("__smartFilter"), "it edits the recorded filter"); + assert_eq!(fields["reduceNoise"].as_f64(), Some(10.0), "the spec default, not the remembered 60"); + assert_eq!(fields["amount"].as_f64(), Some(125.0)); + assert_eq!(fields["radius"].as_f64(), Some(2.0), "recorded values beat the remembered 9"); + + // A plain Filter menu dialog still starts from the remembered values. + let id = crate::filter_dialog::open(&mut app, command).unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["reduceNoise"].as_f64(), Some(60.0)); +} diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 5efbe128bd..b83646af29 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -101,14 +101,30 @@ fn moving_rect(app: &PhotocraftApp, tool: Tool) -> Option<[f64; 4]> { union(st.selected_layers().into_iter().filter_map(|id| layer_rect(&st.doc, id))) } -/// The selection's bounds where it is shown: moved by its floating piece's offset. +/// The bounds of the pixels a selection moves, where they are shown: its floating piece's outline +/// at its offset (#7538), else (nothing cut yet, or a mask or empty piece) the selection's bounds. fn selection_rect(app: &PhotocraftApp) -> Option<[f64; 4]> { let st = app.session.active()?; - let (dx, dy) = photocraft_engine::float_cmds::floating(st).map_or((0, 0), |f| f.offset); - let r = st.doc.selection.as_ref()?.content_bounds().translate(dx, dy); + let sel = st.doc.selection.as_ref()?; + let r = match photocraft_engine::float_cmds::floating(st) { + Some(f) => f.parts.outline().unwrap_or(sel).content_bounds().translate(f.offset.0, f.offset.1), + None => sel.content_bounds(), + }; (!r.is_empty()).then(|| [r.x0, r.y0, r.x1, r.y1].map(f64::from)) } +/// A press cut the selected pixels after snapping began (`filter_event` runs before the tools): +/// the move now snaps the cut piece's outline, not the selection it was cut with (#7538). +pub(crate) fn follow_cut(app: &mut PhotocraftApp) { + let Some(rect) = selection_rect(app) else { return }; + if let Some(snap) = app.prefs_rt.snap.as_mut() { + match &mut snap.gesture { + Gesture::Move { rect: r } | Gesture::SelectionMove { rect: r } => *r = rect, + _ => {} + } + } +} + fn is_point_tool(t: Tool) -> bool { matches!( t, @@ -235,6 +251,12 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // doesn't snap to its own layer. let exclude = if cut { app.session.active().map(|s| s.selected_layers()).unwrap_or_default() } else { Vec::new() }; selection_rect(app).map(|rect| (Gesture::SelectionMove { rect }, exclude)) + } else if tool.is_type() + && let Some(layer) = app.ui.text_edit.as_ref().map(|e| LayerId(e.layer)) + { + // Editing type: a paragraph-box handle drag snaps to guides and other layers, not to the + // text being resized; a press in the text places the caret where it is (#6819). + crate::type_tool::box_handle_at(app, layer, p[0], p[1]).map(|_| (Gesture::Point, vec![layer])) } else if is_point_tool(tool) { Some((Gesture::Point, Vec::new())) } else { @@ -250,7 +272,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { // A guide never snaps to itself (or other guides). targets = targets.filtered(|k| k != SnapKind::Guide); } - if matches!(gesture, Gesture::SelectionMove { .. }) { + if matches!(gesture, Gesture::SelectionMove { .. }) || (matches!(gesture, Gesture::Move { .. }) && crate::move_ui::moves_selected_pixels_with(app, tool)) { // A moving selection never snaps back to where it started. targets = targets.filtered(|k| k != SnapKind::Selection); } @@ -282,7 +304,7 @@ fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] { let tol = tolerance(app); let Some(snap) = app.prefs_rt.snap.as_ref() else { return p }; - if snap.disabled { + if !snap_on(app) || snap.disabled { app.prefs_rt.snap_lines.clear(); return p; } @@ -485,7 +507,8 @@ pub fn snap_guide(app: &mut PhotocraftApp, vertical: bool, pos: f64) -> f64 { return pos; } let Some(st) = app.session.active() else { return pos }; - let key = (st.doc.id, st.revision); + // The Snap To options (grid, layers, document bounds, grid step) change the targets too. + let key = (st.doc.id, st.revision, options(app)); if app.prefs_rt.guide_targets.as_ref().is_none_or(|(k, _)| *k != key) { let t = build(app, &[], false).filtered(|k| k != SnapKind::Guide); app.prefs_rt.guide_targets = Some((key, t)); @@ -499,10 +522,15 @@ fn hex_color(s: &str, fallback: Color32) -> Color32 { photocraft_engine::prefs::parse_hex(s).map_or(fallback, |c| Color32::from_rgb(c[0], c[1], c[2])) } +/// The Smart Guides colour from Preferences › Guides, Grid & Slices (magenta by default). +pub(crate) fn smart_color(app: &PhotocraftApp) -> Color32 { + hex_color(&app.session.prefs().guides_grid_and_slices.smart_guide_color, Color32::from_rgb(255, 0, 255)) +} + /// Draw smart guides (and the moving bounds) for the drag in progress. pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(snap) = &app.prefs_rt.snap else { return }; - let smart_color = hex_color(&app.session.prefs().guides_grid_and_slices.smart_guide_color, Color32::from_rgb(255, 0, 255)); + let smart_color = smart_color(app); // The moved layers' bounds follow the Move tool (Free Transform draws its own box). if let (Gesture::Move { rect }, Some(d)) = (&snap.gesture, app.drag.as_ref().and_then(|d| d.points.last()).map(|p| [p[0] - snap.start[0], p[1] - snap.start[1]])) @@ -555,6 +583,43 @@ mod tests { app } + /// #7538: moving selected pixels snaps (and frames) the pixels, not a loose marquee round them. + /// Snapping starts before the press cuts them, so the cut retargets it to their outline. + #[test] + fn moving_selected_pixels_snaps_their_bounds_not_the_marquee() { + for alt in [false, true] { + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + let m = egui::Modifiers { alt, ..Default::default() }; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::Move { rect: [40.0, 40.0, 90.0, 80.0] }), "alt {alt}: the blue box, not the marquee"); + // 263 px right puts the box's left edge 3 px from the target's (the marquee's 20 px off). + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 323.0, y: 100.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + app.run("select.drop", json!({})).unwrap(); + let sel = app.session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds().x0); + assert_eq!(sel, Some(300), "alt {alt}: snapped to the target's edge"); + } + // A marquee's ⌘-drag cuts them the same way. + let mut app = app_with_box(); + app.ui.tool = Tool::RectMarquee; + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + let cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, cmd); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::SelectionMove { rect: [40.0, 40.0, 90.0, 80.0] }), "⌘-drag with the marquee"); + // The Background has no transparency to shrink to. + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + app.run("layer.select", json!({"name": "Background"})).unwrap(); + app.run("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap(); + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, egui::Modifiers::NONE); + let gesture = app.prefs_rt.snap.as_ref().map(|s| s.gesture.clone()); + assert_eq!(gesture, Some(Gesture::Move { rect: [20.0, 20.0, 120.0, 110.0] }), "the Background"); + } + fn mover_bounds(app: &PhotocraftApp) -> photocraft_geom::Rect { let st = app.session.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() @@ -693,6 +758,20 @@ mod tests { assert!(!override_held(cmd) && override_held(ctrl)); } + #[test] + fn snap_off_disables_all_snapping_even_with_smart_guides() { + let mut app = app_with_box(); + app.ui.tool = Tool::Move; + assert!(app.ui.view.show.smart_guides); + app.ui.extras.snap = false; + app.ui.tool_options.move_auto_select = false; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 323.0, y: 100.0 }, m); + assert_eq!(mover_bounds(&app).x0, 303); + assert!(app.prefs_rt.snap_lines.is_empty()); + } + #[test] fn marquee_corner_snaps_to_guides_and_document_edges() { let mut app = app_with_box(); @@ -1154,10 +1233,35 @@ mod tests { assert_eq!(snap_guide(&mut app, true, 297.0), 300.0); assert_eq!(snap_guide(&mut app, false, 150.0), 150.0, "document centre"); assert_eq!(snap_guide(&mut app, false, 170.0), 170.0); + // Dragging an existing guide with the Move tool while a selection is active still snaps to the selection. + app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap(); + app.ui.tool = Tool::Move; + begin(&mut app, [10.0, 50.0], egui::Modifiers::NONE); + assert_eq!(apply(&mut app, [98.0, 50.0], egui::Modifiers::NONE)[0], 100.0); app.ui.extras.snap = false; assert_eq!(snap_guide(&mut app, true, 297.0), 297.0); } + /// The cached ruler-guide targets follow the Snap To options, not just document edits. + #[test] + fn guide_targets_follow_snap_to_options() { + let mut app = app_with_box(); + app.run( + "prefs.set", + json!({"values": {"guidesGridAndSlices.gridlineEvery": 100, "guidesGridAndSlices.gridUnit": "pixels", "guidesGridAndSlices.subdivisions": 4}}), + ) + .unwrap(); + app.ui.extras.grid = false; + assert_eq!(snap_guide(&mut app, true, 23.0), 23.0, "grid hidden"); + app.ui.extras.grid = true; + app.ui.view.snap_to.grid = true; + assert_eq!(snap_guide(&mut app, true, 23.0), 25.0, "grid shown"); + app.ui.extras.grid = false; + app.ui.view.snap_to.layers = false; + assert_eq!(snap_guide(&mut app, true, 297.0), 297.0, "Snap To Layers off"); + } + #[test] fn percentage_grid_snaps_at_separate_axis_intervals() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/spring_tools/tests.rs b/crates/ui-egui/src/spring_tools/tests.rs index eef327c096..e3d13a043a 100644 --- a/crates/ui-egui/src/spring_tools/tests.rs +++ b/crates/ui-egui/src/spring_tools/tests.rs @@ -210,6 +210,7 @@ fn letters_type_while_editing_text() { let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "a", "size": 40})).unwrap()["layer"].as_u64().unwrap(); h.state_mut().ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 1, anchor: 1, session: "spring-test".into(), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 974d3fde35..372bad8474 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -3,6 +3,74 @@ use serde::{Deserialize, Serialize}; +/// Photoshop-style Layers panel display preferences; part of serialized UI state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LayerThumbnailSize { + None, + Small, + #[default] + Medium, + Large, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LayerThumbnailContents { + LayerBounds, + #[default] + EntireDocument, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayersPanelOptions { + pub thumbnail_size: LayerThumbnailSize, + /// Thumbnail Contents in the Panel Options dialog. Not saved with these options: the dialog + /// shows and sets `interface.clipThumbnailsToLayerBounds` (Layer Bounds = clipped, #4281), + /// which the panel menu's Clip Thumbnails to Layer Bounds toggles too. + #[serde(skip)] + pub thumbnail_contents: LayerThumbnailContents, + pub show_filters: bool, + pub use_default_masks_on_fill_layers: bool, + pub expand_new_effects: bool, + pub add_copy_to_copied_layers_and_groups: bool, + pub preview_on_canvas_when_reordering_layers: bool, + pub show_layer_mask_badges: bool, +} + +impl LayersPanelOptions { + /// Every option at the Photoshop default: Medium thumbnails, filters shown, new effects expanded, no mask badges. + pub fn defaults() -> Self { + Self { + thumbnail_size: LayerThumbnailSize::Medium, + thumbnail_contents: LayerThumbnailContents::EntireDocument, + show_filters: true, + use_default_masks_on_fill_layers: true, + expand_new_effects: true, + add_copy_to_copied_layers_and_groups: true, + preview_on_canvas_when_reordering_layers: true, + show_layer_mask_badges: false, + } + } +} + +impl Default for LayersPanelOptions { + /// Same as [`LayersPanelOptions::defaults`], so serde fills missing fields with the Photoshop defaults. + fn default() -> Self { + Self::defaults() + } +} + +/// Pending dialog data is transient: Cancel never changes persisted options. +#[derive(Clone, Debug, PartialEq)] +pub enum LayersPanelDialog { + PanelOptions(LayersPanelOptions), + NewLayer(String), + NewGroup(String), + LockLayers { transparency: bool, pixels: bool, position: bool, artboard: bool, all: bool }, +} + /// Shared selection and capture for a point curve; document parameters remain with the host. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -404,6 +472,10 @@ pub struct Panels { /// What Tab hid (toolbar, options bar, dock), so a second Tab brings back just those. #[serde(default, skip_serializing_if = "Option::is_none")] pub hidden_by_tab: Option<[bool; 3]>, + /// Tab in Full Screen Mode shows the menu bar, toolbar, options bar and dock over the canvas + /// (#6760). Only a view of the moment: not saved, and the panels' own flags stay untouched. + #[serde(skip)] + pub full_screen_chrome: bool, /// The right dock's icon rail. An app that embeds PhotoCraft's UI can hide it for a simpler view. #[serde(default = "yes")] pub rail: bool, @@ -435,6 +507,7 @@ impl Default for Panels { toolbar_double: false, dock: true, hidden_by_tab: None, + full_screen_chrome: false, rail: true, menu_bar: true, gradient_editor: false, @@ -483,11 +556,53 @@ pub struct View { /// Canvas camera rotation in degrees, wrapped to (−180, 180]. Turns the view, not the pixels. #[serde(default)] pub rotation: f32, + /// Transient presentation state for a discrete zoom command; the target camera above remains + /// authoritative and this flag is never persisted. + #[serde(skip)] + pub(crate) zoom_animating: bool, + /// Optional document/screen anchor for a pointer-centred Zoom-tool transition. + #[serde(skip)] + pub(crate) zoom_anchor: Option<([f64; 2], [f32; 2])>, + #[serde(skip)] + pub(crate) display_camera: Option<(f32, [f32; 2])>, + #[serde(skip)] + pub(crate) zoom_transition: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ZoomTransition { + pub from: (f32, [f32; 2]), + pub target: (f32, [f32; 2]), + pub started: f64, +} + +impl View { + /// Start a discrete zoom only when Animated Zoom is enabled; otherwise discard the + /// presentation camera so the requested camera takes effect immediately. + pub(crate) fn request_zoom_animation(&mut self, enabled: bool) { + self.zoom_animating = enabled; + self.zoom_anchor = None; + if !enabled { + self.zoom_transition = None; + self.display_camera = Some((self.zoom, self.center)); + } + } } impl Default for View { fn default() -> Self { - Self { zoom: 1.0, center: [0.0, 0.0], fit_pending: true, fill_pending: false, doc_size: [0, 0], rotation: 0.0 } + Self { + zoom: 1.0, + center: [0.0, 0.0], + fit_pending: true, + fill_pending: false, + doc_size: [0, 0], + rotation: 0.0, + zoom_animating: false, + zoom_anchor: None, + display_camera: None, + zoom_transition: None, + } } } @@ -541,6 +656,14 @@ pub struct ToolOptions { pub type_size: f32, pub type_aa: String, pub type_align: String, + /// Character/Paragraph panel defaults beyond the options bar, in `type.setStyle`'s + /// parameter format. Kept as overrides so saved engine defaults still supply unset fields. + /// OpenType features and variable-font axes are merged per tag/axis. + #[serde(default)] + pub type_style_defaults: serde_json::Map, + /// Type tool: paragraph direction of new type, "auto" (from the first strong character), + /// "ltr" or "rtl" (TYPE-218-2). + pub type_direction: String, /// Clone Stamp / Healing Brush. pub clone_aligned: bool, /// Pattern Stamp: lock the tile origin in document space across strokes. @@ -605,6 +728,10 @@ pub struct ToolOptions { pub marquee_width: f32, #[serde(default = "one")] pub marquee_height: f32, + /// The unit Fixed Size's Width and Height show (a [`photocraft_engine::prefs::Unit`] name, + /// picked by right-clicking them); the size itself stays in pixels (#7910). + #[serde(default = "default_marquee_unit")] + pub marquee_unit: String, /// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls. /// Auto-Select is on with the "group" target by default, as in current Photoshop; /// ⌘/Ctrl-click inverts it. @@ -738,6 +865,10 @@ fn default_marquee_style() -> String { "normal".into() } +fn default_marquee_unit() -> String { + "pixels".into() +} + fn one() -> f32 { 1.0 } @@ -833,6 +964,8 @@ impl Default for ToolOptions { type_size: 48.0, type_aa: "sharp".into(), type_align: "left".into(), + type_style_defaults: serde_json::Map::new(), + type_direction: "auto".into(), clone_aligned: true, pattern_stamp_aligned: true, pattern_stamp_impressionist: false, @@ -869,6 +1002,7 @@ impl Default for ToolOptions { marquee_style: default_marquee_style(), marquee_width: 1.0, marquee_height: 1.0, + marquee_unit: default_marquee_unit(), move_auto_select: true, move_target: default_move_target(), move_show_transform: false, @@ -1016,10 +1150,25 @@ pub enum MadeLayer { Place, } +/// A paragraph-box handle drag: the handle, and the box (text-space) and layer transform as +/// they were at the press. Every move recomputes from this snapshot, so ⌥ pressed or released +/// mid-drag mirrors about the centre the box started with — as if it had been held from the +/// start. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoxResize { + pub handle: u8, + pub shape: [f32; 4], + pub aff: photocraft_geom::Affine, +} + /// In-progress inline type editing (Type tool). Offsets are character indices. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { pub layer: u64, + /// Id of the document being edited (layer ids can repeat across documents, e.g. one .pcraft + /// opened twice). `None`: the active document. + #[serde(default)] + pub document: Option, pub caret: usize, pub anchor: usize, /// History coalescing key: the whole editing session is one "Edit Type Layer" step. @@ -1033,7 +1182,7 @@ pub struct TextEdit { pub dragging: bool, /// Paragraph-box handle being dragged (0-3 corners from top-left clockwise, 4-7 top/right/bottom/left edges). #[serde(skip)] - pub resize: Option, + pub resize: Option, /// IME composition in progress: (start, length) in characters. The preedit text lives in the /// layer so it lays out like typed text; each IME update replaces it. #[serde(skip)] @@ -1097,6 +1246,15 @@ pub struct DockTabs { pub brushes: usize, } +/// Sliders under the Color panel's field: hue/saturation/brightness or red/green/blue (#7172). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColorPanelSliderMode { + #[default] + Hsb, + Rgb, +} + /// Color panel state. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -1106,6 +1264,9 @@ pub struct ColorPanelState { /// Color Picker › Only Web Colors, kept across opens like Photoshop (#4995). #[serde(default)] pub web_only: bool, + /// HSB or RGB sliders under the colour field (#7172). + #[serde(default)] + pub slider_mode: ColorPanelSliderMode, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -1122,6 +1283,10 @@ pub struct UiState { /// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated. #[serde(default)] pub recent_files: Vec, + /// Recently picked type families, most-recent first (the font menu's Recent group). Capped by + /// Preferences ▸ Type ▸ Recent Fonts; de-duplicated. + #[serde(default)] + pub recent_fonts: Vec, /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, @@ -1205,6 +1370,9 @@ pub struct UiState { /// Pen path under construction. #[serde(default)] pub pen: Option, + /// The document the pen path is drawn on; switching away commits it to that Work Path. + #[serde(default)] + pub pen_doc: Option, #[serde(default)] pub stroke_editor: Option, /// Direct Selection tool: selected anchors and the drag in progress (#790). @@ -1219,6 +1387,12 @@ pub struct UiState { /// Layers panel kind filter ("pixel", "adjustment", "type", "shape", "smart"); empty = all. #[serde(default)] pub layer_filter: Vec, + /// Thumbnail dimensions, content cropping and filter-strip visibility. + #[serde(default)] + pub layers_panel_options: LayersPanelOptions, + /// Transient dialog; never restored with a workspace. + #[serde(skip)] + pub layers_panel_dialog: Option, /// Layers panel filter type (#5048): kind (above), name or blend mode. #[serde(default)] pub layer_filter_by: crate::layer_filter_ui::FilterBy, @@ -1361,6 +1535,7 @@ impl Default for UiState { group_tools: Vec::new(), spring: None, recent_files: Vec::new(), + recent_fonts: Vec::new(), text_edit: None, caret_blink_time: 0.0, text_edit_return_tool: None, @@ -1398,10 +1573,13 @@ impl Default for UiState { artboard_tool: Default::default(), shell: Default::default(), layer_filter: Vec::new(), + layers_panel_options: LayersPanelOptions::default(), + layers_panel_dialog: None, layer_filter_by: Default::default(), layer_filter_name: String::new(), layer_filter_mode: default_layer_filter_mode(), pen: None, + pen_doc: None, stroke_editor: None, direct_selection: Default::default(), corner_radius: None, diff --git a/crates/ui-egui/src/stroke_constraint.rs b/crates/ui-egui/src/stroke_constraint.rs index a7cd51cb93..51760cc6cb 100644 --- a/crates/ui-egui/src/stroke_constraint.rs +++ b/crates/ui-egui/src/stroke_constraint.rs @@ -23,9 +23,10 @@ pub struct Axis { pub dir: Option<[f64; 2]>, } -/// Tools whose drag ⇧ constrains: every painting tool and the Gradient tool. +/// Tools whose drag ⇧ constrains: every painting tool, the Gradient tool, and the Freeform Pen +/// (Photoshop's freehand path segment with ⇧, #7171). pub fn applies(tool: Tool) -> bool { - tool.is_brushlike() || tool == Tool::Gradient + tool.is_brushlike() || tool == Tool::Gradient || tool == Tool::FreeformPen } /// Tools that ⇧-click connects to the previous stroke with a straight line. diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..1af740b036 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -2,10 +2,11 @@ //! preset browser: groups with disclosure triangles, a colour grid with name tooltips, a context //! menu (Rename, Delete, Move to), and the footer (size · New Group · New Swatch · Delete). //! -//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type); +//! As in Photoshop, clicking a swatch sets the foreground colour (and recolours selected type or +//! fills the selected shape layer); //! Alt- or Ctrl/Cmd-clicking sets the background colour; double-clicking renames it. The //! panel menu (the group's "…" button) adds New Swatch, New Group, Import/Replace (`.aco`, -//! `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a +//! `.act`, `.ase`), Export (`.aco`) and Export for Exchange (`.ase`) and Reset. Every action is a //! `swatches.*` engine command; this module only draws and dispatches. Colours are shown in the //! RGB working space through `photocraft-cms` (cached per library revision and colour settings). @@ -51,8 +52,10 @@ fn colors(app: &PhotocraftApp, ctx: &egui::Context) -> Arc>> { pub fn use_swatch(app: &mut PhotocraftApp, name: &str, background: bool) { let target = if background { "background" } else { "foreground" }; if run(app, "swatches.use", json!({"swatch": name, "target": target})).is_some() && !background { - // Clicking a swatch while characters are selected recolours them (#364). + // Clicking a swatch while characters are selected recolours them (#364), and with a + // shape layer selected fills the shape (#5568). crate::type_tool::foreground_changed(app); + crate::shape_fill_ui::foreground_changed(app, None); } } @@ -139,7 +142,7 @@ fn new_group(app: &mut PhotocraftApp) { /// Command params for a picked file: a real path keeps the journal small; browser files arrive /// as bytes. -fn source(name: &str, bytes: &[u8]) -> Value { +pub(crate) fn source(name: &str, bytes: &[u8]) -> Value { #[cfg(not(target_arch = "wasm32"))] if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() { return json!({"path": name}); @@ -148,11 +151,16 @@ fn source(name: &str, bytes: &[u8]) -> Value { json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)}) } -/// Import an `.aco`/`.ase` file's bytes (File › Open, drag and drop, the panel menu). +/// Import an `.aco`/`.act`/`.ase` file's bytes (File › Open, drag and drop, the panel menu). pub fn import_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8], replace: bool) -> Result { let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.trim().is_empty()); let mut p = source(name, bytes); p["mode"] = json!(if replace { "replace" } else { "append" }); + // Bytes without a path (the browser, drag and drop) are sniffed by content, and `.act` has + // no signature: one starting 00 01 or 00 02 reads as `.aco`. Its file name decides. + if std::path::Path::new(name).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("act")) { + p["format"] = json!("act"); + } if let Some(stem) = stem { p["group"] = json!(stem); } @@ -273,6 +281,43 @@ mod tests { use egui::{Modifiers, vec2}; use egui_kittest::Harness; + /// Bytes without a path (the browser) go by the file name: an `.act` whose first color is + /// 00 01 xx looks like an `.aco` header by content. + #[test] + fn an_act_from_bytes_imports_by_its_file_name() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut colors = vec![[10u8, 20, 30]; 256]; + colors[0] = [0, 1, 5]; + let bytes = photocraft_psd::act::write(&colors, None, None).unwrap(); + let r = import_bytes(&mut app, "Palette.act", &bytes, true).unwrap(); + assert_eq!((r["format"].as_str(), r["count"].as_u64()), (Some("act"), Some(256))); + } + + /// #5568: clicking a swatch with a shape layer selected fills the shape with it, one undo + /// step each, a gradient fill included. + #[test] + fn swatch_click_fills_the_selected_shape() { + use photocraft_doc::{Fill, LayerContent, LayerId}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = LayerId(app.run("shape.create", json!({"rect": [8, 8, 40, 30], "fill": {"gradient": {}}})).unwrap()["layer"].as_u64().unwrap()); + let fill = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => sh.fill.clone(), + _ => panic!("not a shape layer"), + }; + let steps = |app: &PhotocraftApp| app.session.active().unwrap().history.entries().len(); + let before = steps(&app); + let names: Vec = app.session.presets.swatches[0].items.iter().take(2).map(|s| s.name.clone()).collect(); + use_swatch(&mut app, &names[0], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[0]); + assert_eq!(steps(&app), before + 1); + use_swatch(&mut app, &names[1], false); + let fg = app.session.tools.foreground; + assert_eq!(fill(&app), Some(Fill::Solid(photocraft_doc::Color::rgba(fg[0], fg[1], fg[2], 1.0))), "{} filled the shape", names[1]); + assert_eq!(steps(&app), before + 2, "each swatch click is its own undo step"); + } + fn click(h: &mut Harness<'_, PhotocraftApp>, p: egui::Pos2, modifiers: Modifiers) { h.hover_at(p); // Held modifiers, as when the user holds Alt while clicking. @@ -327,6 +372,7 @@ mod tests { app.ui.tool = Tool::Type; app.ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 0, anchor: 5, session: "s".into(), @@ -442,6 +488,32 @@ mod tests { assert!(out[0].1.starts_with(b"ASEF")); } + /// The Linux save dialog shows the export's name without its extension, `Swatches`, and asks + /// only about that name. The export adds `.aco`, so with `Swatches.aco` in the folder it asks + /// before replacing it. + #[test] + fn export_asks_before_replacing_the_file_its_added_extension_names() { + use crate::file_dialog::FileDialogAnswer; + let written: std::rc::Rc>> = Default::default(); + let w = written.clone(); + let services = crate::Services { + file_dialog: Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("/swatches/Swatches".into()))]).0), + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })), + file_stamp: Some(Box::new(|p: &str| (p == "/swatches/Swatches.aco").then_some(crate::FileStamp { len: 1, modified: None }))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + export_dialog(&mut app, "aco").unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + let id = app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the export asks first"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/swatches/Swatches.aco"]); + } + /// #3658: dragging a group header reorders the folders, and the new order sticks in the UI /// state (UI state is serde, so it saves and reloads with the rest of the panel state). #[test] diff --git a/crates/ui-egui/src/targa_options_ui.rs b/crates/ui-egui/src/targa_options_ui.rs index 5a8b47d589..446bd0cb7f 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -47,7 +47,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { if app.save_options_open() { - return Err("Answer the TIFF, Targa or DDS Options before starting another save".into()); + return Err(crate::SAVE_OPTIONS_OPEN.into()); } let doc = app.active_doc_id()?; let bits = app.session.active().map_or(TgaBits::Bits24, |st| photocraft_io::tga_default_bits(&st.doc)); @@ -81,22 +81,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if ok || (!cancel && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter))) { let Some(p) = app.targa_options.take() else { return }; let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; + // A plain Save writes whichever file the action is played on (#3531): no path. let mut step = if p.record == "file.save" { serde_json::json!({}) } else { serde_json::json!({"path": p.path}) }; step["tgaBits"] = p.bits.bits().into(); - let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); - if written.is_ok() { - // An action being recorded keeps the Save As or Save a Copy with this answer (#2032). - app.session.journal.push((p.record.into(), step)); - } - match written { - Err(e) => { - app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); - app.ui.status_error = true; - } - Ok(Some(_)) if !p.copy => crate::discard_ui::saved_document(app, ctx, p.doc), - // A background save releases the close prompt when it finishes (`jobs_ui`). - Ok(_) => {} - } + app.write_answered_save(ctx, p.doc, p.path, &settings, p.copy, (p.record, step)); } else if cancel { app.targa_options = None; app.ui.status = tl!("Save cancelled").into(); diff --git a/crates/ui-egui/src/texel_guard.rs b/crates/ui-egui/src/texel_guard.rs new file mode 100644 index 0000000000..28bca7c55b --- /dev/null +++ b/crates/ui-egui/src/texel_guard.rs @@ -0,0 +1,185 @@ +//! Intel Iris Xe on DirectX 12: no egui texture upload carries an all-zero texel, because the +//! driver shows stale memory where it does (the red blocks in Brush Preset picker strokes and +//! around tool icons). +//! +//! wgpu creates every texture on Iris Xe as a committed resource (its workaround for +//! gfx-rs/wgpu#3552, where suballocated textures had the same symptom) and, since wgpu added it, +//! with `D3D12_HEAP_FLAG_CREATE_NOT_ZEROED`. egui-wgpu fills each new texture with one full copy, +//! so wgpu never clears it first. On this driver the copy leaves 4×4 blocks whose texels are all +//! (0, 0, 0, 0) showing whatever the memory last held, typically a freed texture's pixels. Blocks +//! with any other value upload correctly, which is why the garbage sits only in the empty corners +//! of a thumbnail, never next to what is drawn. It shows up after textures have been freed and +//! made again: scrolling the picker (stroke previews come and go), switching brush tools (the +//! options bar and toolbar icons are rasterized again). +//! +//! [`TexelGuard`] rewrites fully transparent texels in every upload as premultiplied +//! (0, 0, 0, 1) before eframe hands the upload to the renderer: alpha 1/255 black, which darkens +//! what is underneath by less than one 8-bit level. It is installed only on the affected adapter +//! ([`affected`]); `PHOTOCRAFT_TEXEL_GUARD=1` forces it on (to check it on any GPU) and `=0` off. + +use std::sync::Arc; + +use eframe::wgpu; +use egui::{Color32, FullOutput, ImageData, TexturesDelta}; + +/// What a fully transparent texel is uploaded as. +pub const NEAR_CLEAR: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 1); + +/// Whether texture uploads on this adapter need the guard (`env`: `PHOTOCRAFT_TEXEL_GUARD`). +pub fn affected(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool { + match env { + Some("1") => true, + Some("0") => false, + // The same adapter test wgpu uses for its #3552 workaround. + _ => info.backend == wgpu::Backend::Dx12 && info.name.contains("Iris(R) Xe"), + } +} + +/// Rewrite the fully transparent texels of every upload in `delta` as [`NEAR_CLEAR`]; returns +/// how many changed. Images without one are not copied. +pub fn guard(delta: &mut TexturesDelta) -> usize { + let mut changed = 0; + for d in delta.set.values_mut().flatten() { + let ImageData::Color(image) = &mut d.image; + if !image.pixels.contains(&Color32::TRANSPARENT) { + continue; + } + for p in &mut Arc::make_mut(image).pixels { + if *p == Color32::TRANSPARENT { + *p = NEAR_CLEAR; + changed += 1; + } + } + } + changed +} + +/// The egui plugin that runs [`guard`] on each frame's texture uploads. +pub struct TexelGuard; + +impl egui::Plugin for TexelGuard { + fn debug_name(&self) -> &'static str { + "TexelGuard" + } + + fn output_hook(&mut self, _ctx: &egui::Context, output: &mut FullOutput) { + guard(&mut output.textures_delta); + } +} + +/// Install [`TexelGuard`] on `ctx` when the window's adapter needs it; returns whether it did. +/// Call once, with the adapter eframe renders the window with. +pub fn install(ctx: &egui::Context, info: &wgpu::AdapterInfo) -> bool { + let on = affected(info, std::env::var("PHOTOCRAFT_TEXEL_GUARD").ok().as_deref()); + if on && ctx.plugin_opt::().is_none() { + ctx.add_plugin(TexelGuard); + log::info!("texture uploads: transparent texels written as alpha 1/255 ({}, {:?}; gfx-rs/wgpu#3552)", info.name, info.backend); + } + on +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::epaint::ImageDelta; + use egui::{ColorImage, TextureId, TextureOptions}; + + fn adapter(name: &str, backend: wgpu::Backend) -> wgpu::AdapterInfo { + wgpu::AdapterInfo { name: name.into(), ..wgpu::AdapterInfo::new(wgpu::DeviceType::IntegratedGpu, backend) } + } + + #[test] + fn only_iris_xe_on_dx12_is_affected_unless_forced() { + assert!(affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), None)); + assert!(!affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Vulkan), None)); + assert!(!affected(&adapter("Intel(R) UHD Graphics 620", wgpu::Backend::Dx12), None)); + assert!(!affected(&adapter("NVIDIA GeForce RTX 4070", wgpu::Backend::Dx12), None)); + assert!(affected(&adapter("NVIDIA GeForce RTX 4070", wgpu::Backend::Dx12), Some("1"))); + assert!(!affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), Some("0"))); + assert!(affected(&adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12), Some("nonsense"))); + } + + #[test] + fn transparent_texels_upload_as_near_clear_and_nothing_else_changes() { + // A stroke-strip-like image: mostly transparent, a grey texel and a faint one. + let grey = Color32::from_gray(200); + let faint = Color32::from_rgba_premultiplied(1, 1, 1, 2); + let mut px = vec![Color32::TRANSPARENT; 16]; + px[5] = grey; + px[6] = faint; + let strip = Arc::new(ColorImage::new([4, 4], px)); + let opaque = Arc::new(ColorImage::filled([2, 2], grey)); + let mut delta = TexturesDelta::default(); + delta.set.insert(TextureId::Managed(1), [ImageDelta::full(ImageData::Color(strip.clone()), TextureOptions::LINEAR)].into_iter().collect()); + delta.set.insert(TextureId::Managed(2), [ImageDelta::full(ImageData::Color(opaque.clone()), TextureOptions::LINEAR)].into_iter().collect()); + // A partial update (the font atlas grows this way) is guarded too. + let patch = Arc::new(ColorImage::new([2, 1], vec![Color32::TRANSPARENT, grey])); + delta.set.insert(TextureId::Managed(0), [ImageDelta::partial([3, 4], ImageData::Color(patch), TextureOptions::LINEAR)].into_iter().collect()); + + assert_eq!(guard(&mut delta), 14 + 1); + let image = |id| { + let ImageData::Color(i) = &delta.set[&TextureId::Managed(id)][0].image; + i.clone() + }; + let s = image(1); + assert!(!s.pixels.contains(&Color32::TRANSPARENT)); + assert_eq!((s.pixels[5], s.pixels[6], s.pixels[0]), (grey, faint, NEAR_CLEAR)); + // The caller's image is untouched (copy on write), and an image with nothing to change is + // not copied at all. + assert!(strip.pixels.contains(&Color32::TRANSPARENT)); + assert!(Arc::ptr_eq(&image(2), &opaque)); + assert_eq!(image(0).pixels, vec![NEAR_CLEAR, grey]); + // Idempotent: a second pass finds nothing. + assert_eq!(guard(&mut delta), 0); + delta.clear(); + } + + #[test] + fn near_clear_is_premultiplied_and_invisible_over_any_background() { + // Premultiplied blend: out = src + dst × (1 − a). Over every 8-bit level the result moves + // by at most one level. + let a = f32::from(NEAR_CLEAR.a()) / 255.0; + assert!(NEAR_CLEAR.r() <= NEAR_CLEAR.a() && NEAR_CLEAR.g() <= NEAR_CLEAR.a() && NEAR_CLEAR.b() <= NEAR_CLEAR.a()); + for dst in 0..=255u8 { + let out = (f32::from(NEAR_CLEAR.r()) + f32::from(dst) * (1.0 - a)).round(); + assert!((f32::from(dst) - out).abs() <= 1.0, "{dst} -> {out}"); + } + } + + #[test] + fn the_plugin_guards_a_frames_uploads() { + let ctx = egui::Context::default(); + ctx.add_plugin(TexelGuard); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let img = ColorImage::new([8, 8], vec![Color32::TRANSPARENT; 64]); + let tex = ui.ctx().load_texture("texel-guard-test", img, TextureOptions::LINEAR); + ui.image((tex.id(), egui::vec2(8.0, 8.0))); + }); + let uploaded: Vec<_> = out + .textures_delta + .set + .values() + .flatten() + .filter_map(|d| { + let ImageData::Color(i) = &d.image; + (i.size == [8, 8]).then(|| i.clone()) + }) + .collect(); + assert_eq!(uploaded.len(), 1); + assert!(uploaded[0].pixels.iter().all(|p| *p == NEAR_CLEAR)); + out.textures_delta.clear(); + } + + #[test] + fn install_adds_the_plugin_once_and_only_where_needed() { + if std::env::var_os("PHOTOCRAFT_TEXEL_GUARD").is_some() { + return; + } + let ctx = egui::Context::default(); + assert!(!install(&ctx, &adapter("Apple M2", wgpu::Backend::Metal))); + assert!(ctx.plugin_opt::().is_none()); + let iris = adapter("Intel(R) Iris(R) Xe Graphics", wgpu::Backend::Dx12); + assert!(install(&ctx, &iris) && install(&ctx, &iris)); + assert!(ctx.plugin_opt::().is_some()); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0b2cc7c044..d637f193d0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -113,6 +113,23 @@ impl ThemeKind { pub fn is_pro(self) -> bool { matches!(self, ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::ProLightGray | ThemeKind::ProLight) } + /// Whether this theme belongs to the dark appearance family. + pub fn is_dark(self) -> bool { + matches!( + self, + ThemeKind::Studio + | ThemeKind::Pro + | ThemeKind::ProMedium + | ThemeKind::SolarizedDark + | ThemeKind::AdwaitaDark + | ThemeKind::BreezeDark + | ThemeKind::SoftDark + ) + } + /// Whether this theme belongs to the light appearance family. + pub fn is_light(self) -> bool { + !self.is_dark() + } pub fn next(self) -> Self { let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0); Self::ALL[(i + 1) % Self::ALL.len()] @@ -808,17 +825,21 @@ impl Tokens { if self.pro { Color32::from_gray(if self.dark() { 96 } else { 201 }) } else { self.accent_soft } } + /// The 1 px ring round a slider knob, if the theme draws one: the Pro layouts' dark ring, and + /// in the light themes the dim text colour, so their white knob stands out from a pale panel + /// (#7927). The dark Studio-layout themes need none; Classic draws a bevelled knob instead. + pub fn knob_outline(&self) -> Option { + if self.pro { + Some(Color32::from_gray(40)) + } else if self.dark() { + None + } else { + Some(self.text_dim) + } + } + pub fn dark(&self) -> bool { - matches!( - self.kind, - ThemeKind::Studio - | ThemeKind::Pro - | ThemeKind::ProMedium - | ThemeKind::SolarizedDark - | ThemeKind::AdwaitaDark - | ThemeKind::BreezeDark - | ThemeKind::SoftDark - ) + self.kind.is_dark() } } @@ -1261,6 +1282,32 @@ mod tests { (hi + 0.05) / (lo + 0.05) } + /// The light themes outline their white slider knob so its edge holds WCAG's 3:1 non-text + /// contrast against the panels sliders sit on (#7927); the dark ones keep the plain knob. + #[test] + fn light_themes_outline_the_slider_knob() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + // The Pro layouts keep their dark ring (it rims a grey knob), Classic its bevelled knob. + if t.pro || t.bevel { + continue; + } + match t.knob_outline() { + Some(outline) => { + assert!(!t.dark(), "{kind:?}"); + for panel in [t.dock, t.card, t.chrome] { + assert!(contrast(outline, panel) >= 3.0, "{kind:?}: knob outline {outline:?} on {panel:?}"); + } + } + None => assert!(t.dark(), "{kind:?}: a light theme without a knob outline"), + } + } + assert_eq!(Tokens::for_kind(ThemeKind::Pro).knob_outline(), Some(Color32::from_gray(40)), "Pro is unchanged"); + for kind in [ThemeKind::StudioLight, ThemeKind::Adwaita, ThemeKind::BreezeLight, ThemeKind::SoftLight] { + assert!(Tokens::for_kind(kind).knob_outline().is_some(), "{kind:?}"); + } + } + #[test] fn adwaita_themes_parse_and_meet_text_contrast() { for kind in [ThemeKind::Adwaita, ThemeKind::AdwaitaDark] { diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index 25b23ee0f6..dc659840ba 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -67,8 +67,8 @@ pub(crate) fn modal_drag_overlay(ctx: &egui::Context) { } egui::Area::new(Id::new("titlebar-modal-drag-overlay")).order(Order::Tooltip).fixed_pos(area.min).movable(false).sense(Sense::hover()).show(ctx, |ui| { let response = ui.allocate_rect(area, Sense::click_and_drag()); - if response.drag_started() { - ctx.send_viewport_cmd(ViewportCommand::StartDrag); + if response.drag_started_by(PointerButton::Primary) { + start_os_move(ctx); } if response.double_clicked() { let max = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); @@ -266,7 +266,7 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) { let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() }); if resp.drag_started_by(PointerButton::Primary) { - ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + start_os_move(ui.ctx()); } let pressed = resp.is_pointer_button_down_on() && resp.hovered(); if windows { @@ -468,7 +468,7 @@ fn resize_handed_off_id() -> Id { Id::new("titlebar-resize-handed-off") } -/// An OS resize started at `pass`; `released` is the pass whose input ended the press +/// An OS resize or move started at `pass`; `released` is the pass whose input ended the press /// ([`release_after_os_resize`]). #[derive(Clone, Copy, Debug)] struct HandOff { @@ -476,11 +476,26 @@ struct HandOff { released: Option, } -/// The compositor takes the button release of an OS resize (KDE Wayland), so egui kept the edge -/// pressed: the zones stopped hovering until the next click, every other resize (#1581). End the -/// press ourselves on the input after the hand-off, but only while egui still has it down and this -/// input brings no release of its own: where the OS does send it (Windows, X11), nothing is added, -/// so no second release can turn into a click. Call from `raw_input_hook`. +/// The OS took the press at this pass: [`release_after_os_resize`] ends it on the next input. +fn hand_off(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(resize_handed_off_id(), HandOff { pass, released: None })); +} + +/// Move the window from this press. The window manager takes the press of an OS move as it does +/// that of a resize and keeps its release, so egui kept the drag: every other move failed, and a +/// resize right after one (#5725). The same hand-off ends it. +pub(crate) fn start_os_move(ctx: &egui::Context) { + ctx.send_viewport_cmd(ViewportCommand::StartDrag); + hand_off(ctx); +} + +/// The compositor takes the button release of an OS resize or move (KDE Wayland, KWin on X11), so +/// egui kept the edge pressed: the zones stopped hovering until the next click, every other resize +/// (#1581, and #5725 for moves). End the press ourselves on the input after the hand-off, but only +/// while egui still has it down and this input brings no release of its own: where the OS does send +/// it (Windows), nothing is added, so no second release can turn into a click. Call from +/// `raw_input_hook`. pub fn release_after_os_resize(ctx: &egui::Context, raw: &mut egui::RawInput) { let Some(h) = ctx.data(|d| d.get_temp::(resize_handed_off_id())) else { return }; if h.released.is_some() || h.pass >= ctx.cumulative_pass_nr() { @@ -524,8 +539,7 @@ pub fn resize_zones(ui: &mut Ui) { let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); if resp.drag_started_by(PointerButton::Primary) { ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); - let pass = ctx.cumulative_pass_nr(); - ctx.data_mut(|d| d.insert_temp(resize_handed_off_id(), HandOff { pass, released: None })); + hand_off(&ctx); } } // After the zones, so its one-frame cursor wins over theirs. @@ -608,9 +622,13 @@ mod tests { } fn gesture_with_modal(&mut self, path: &[Pos2], modal: bool) -> Vec { - let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]]; + self.gesture_by(path, modal, egui::PointerButton::Primary) + } + + fn gesture_by(&mut self, path: &[Pos2], modal: bool, btn: egui::PointerButton) -> Vec { + let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true, btn)]]; events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)])); - events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]); + events.push(vec![button(*path.last().unwrap_or(&path[0]), false, btn)]); events .into_iter() .flat_map(|e| self.frame_with_modal(e, modal).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()) @@ -654,8 +672,8 @@ mod tests { v } - fn button(p: Pos2, pressed: bool) -> Event { - Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + fn button(p: Pos2, pressed: bool, button: egui::PointerButton) -> Event { + Event::PointerButton { pos: p, button, pressed, modifiers: Default::default() } } #[test] @@ -758,6 +776,23 @@ mod tests { assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); } + #[test] + fn middle_button_drag_in_the_gap_does_not_start_a_window_drag() { + let mut w = Win::new(1440.0); + w.frame(vec![]); + let empty = pos2(980.0, 16.0); + let path = [empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]; + let cmds = w.gesture_by(&path, false, egui::PointerButton::Middle); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}"); + // An ordinary left drag afterwards still moves the window. + let cmds = w.gesture(&path); + assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); + // Same with a modal open (the overlay over the gap). + w.frame_with_modal(vec![], true); + let cmds = w.gesture_by(&path, true, egui::PointerButton::Middle); + assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}"); + } + #[test] fn modal_drag_overlay_does_not_cover_a_dialog_moved_into_the_gap() { let mut w = Win::new(1440.0); @@ -780,7 +815,7 @@ mod tests { let p = pos2(3.0, 300.0); // Press and drag; the compositor takes over, so no release ever arrives. let mut cmds = vec![]; - for e in [vec![Event::PointerMoved(p)], vec![button(p, true)], vec![Event::PointerMoved(p + vec2(6.0, 0.0))]] { + for e in [vec![Event::PointerMoved(p)], vec![button(p, true, egui::PointerButton::Primary)], vec![Event::PointerMoved(p + vec2(6.0, 0.0))]] { cmds.extend(w.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()); } assert!(cmds.contains(&ViewportCommand::BeginResize(ResizeDirection::West)), "{cmds:?}"); @@ -795,7 +830,7 @@ mod tests { assert!(w.gesture(&[p, p + vec2(6.0, 0.0)]).contains(&ViewportCommand::BeginResize(ResizeDirection::West))); } - /// Where the OS does send the release of a resize (Windows, X11), no second one is added: a + /// Where the OS does send the release of a resize (Windows), no second one is added: a /// release right after it could count as a click on what lies under the edge. #[test] fn a_release_the_os_sends_is_not_doubled() { @@ -803,10 +838,10 @@ mod tests { w.frame(vec![]); let p = pos2(3.0, 300.0); w.frame(vec![Event::PointerMoved(p)]); - w.frame(vec![button(p, true)]); + w.frame(vec![button(p, true, egui::PointerButton::Primary)]); w.frame(vec![Event::PointerMoved(p + vec2(6.0, 0.0))]); // The release arrives with the next input. - let mut raw = RawInput { events: vec![button(p + vec2(6.0, 0.0), false)], ..Default::default() }; + let mut raw = RawInput { events: vec![button(p + vec2(6.0, 0.0), false, egui::PointerButton::Primary)], ..Default::default() }; release_after_os_resize(&w.ctx, &mut raw); assert_eq!(raw.events.len(), 1, "{:?}", raw.events); // Or it already arrived in an earlier input: egui has the button up, nothing to end. @@ -818,6 +853,31 @@ mod tests { assert!(raw.events.is_empty(), "{:?}", raw.events); } + /// #5725: the window manager keeps the button release of an OS move just as of a resize, so + /// egui kept the drag: every other move of the window failed, and so did a resize right after one. + #[test] + fn after_an_os_move_the_bar_and_the_edges_take_the_next_press() { + /// Press and drag along `path`; the window manager takes over, so no release ever arrives. + fn handed_off(w: &mut Win, path: &[Pos2]) -> Vec { + let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true, egui::PointerButton::Primary)]]; + events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)])); + events.push(vec![]); + events.into_iter().flat_map(|e| w.frame(e).viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()).collect() + } + let moves = |cmds: &[ViewportCommand]| cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(); + let mut w = Win::new(1440.0); + w.frame(vec![]); + let empty = pos2(980.0, 16.0); + assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space"); + let drag = [empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]; + assert_eq!(moves(&handed_off(&mut w, &drag)), 1); + assert!(!w.ctx.input(|i| i.pointer.primary_down()), "the press ends with the hand-off"); + assert_eq!(moves(&w.gesture(&drag)), 1, "the next move"); + assert_eq!(moves(&handed_off(&mut w, &drag)), 1); + let edge = pos2(3.0, 300.0); + assert!(w.gesture(&[edge, edge + vec2(6.0, 0.0)]).contains(&ViewportCommand::BeginResize(ResizeDirection::West)), "a resize after a move"); + } + #[test] fn edge_zones_resize_unless_maximized() { use ResizeDirection::*; diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index 8b67b96d60..7d66888214 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -127,7 +127,34 @@ pub(crate) fn native_scale(ctx: &egui::Context) -> f32 { /// and Warp splits), the type box, Content-Aware Move's box, the Artboard tool and guides pick /// their arrows here. pub(crate) fn resize(d: [f64; 2], xf: &ViewXform) -> CursorIcon { - let s = xf.map_vec(vec2(d[0] as f32, d[1] as f32)); + resize_on_screen(xf.map_vec(vec2(d[0] as f32, d[1] as f32))) +} + +/// The resize arrow for corner `i` of a box's `quad` (corners in order round the box), as the view +/// shows it: along the corner's bisector, so an upright box's corners always show a diagonal arrow, +/// as in Photoshop, however wide or tall the box is. Aiming from the opposite corner instead gave a +/// box wider than about 2.4:1 the horizontal arrow at its corners (and a tall one the vertical). +pub(crate) fn corner_resize(quad: &[[f64; 2]; 4], i: usize, xf: &ViewXform) -> CursorIcon { + let at = quad[i % 4]; + let edge = |k: usize| { + let to = quad[k % 4]; + let v = xf.map_vec(vec2((at[0] - to[0]) as f32, (at[1] - to[1]) as f32)); + let l = v.length(); + (l.is_finite() && l > 1e-6).then(|| v / l) + }; + let bisector = edge(i + 1).zip(edge(i + 3)).map(|(a, b)| a + b).filter(|s| s.length() > 1e-3); + match bisector { + Some(s) => resize_on_screen(s), + // A collapsed or folded corner has no bisector: aim away from the opposite corner. + None => { + let opposite = quad[(i + 2) % 4]; + resize([at[0] - opposite[0], at[1] - opposite[1]], xf) + } + } +} + +/// The nearest of the four resize arrows to screen direction `s`. +fn resize_on_screen(s: Vec2) -> CursorIcon { let angle = f64::from(s.y).atan2(f64::from(s.x)).rem_euclid(std::f64::consts::PI); match (angle / std::f64::consts::FRAC_PI_4).round() as u8 % 4 { 0 => CursorIcon::ResizeHorizontal, @@ -152,6 +179,12 @@ fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon { if closed { CursorIcon::Grabbing } else { CursorIcon::Grab } } +/// The bitmap [`hand`] hands Windows, for tests that check the Hand's pointer on every platform. +#[cfg(test)] +pub(crate) fn hand_bitmap(ctx: &egui::Context, closed: bool) -> Option { + rasterize(&Shape::Hand { closed }, native_scale(ctx)) +} + /// The Zoom tool's pointer: a magnifier with a plus, or a minus when it zooms `out`. winit maps /// `ZoomIn` and `ZoomOut` to the plain arrow on Windows (#3735), so there it is a bitmap; macOS and /// the Linux themes already draw magnifiers. diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index d68be241f5..a28d549405 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -380,6 +380,80 @@ fn harness() -> Harness<'static, crate::PhotocraftApp> { h } +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn brush_cursor_follows_pen_orientation_6350() { + for control in ["penTilt", "rotation"] { + let mut h = harness(); + h.state_mut() + .run( + "tools.setBrush", + json!({"brush": { + "size": 60, "hardness": 1.0, "roundness": 0.25, + "shapeDynamics": {"enabled": true, "angle": {"control": control}} + }}), + ) + .unwrap(); + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 45.0, ..Default::default() })); + h.run_steps(3); + let initial = h.output().platform_output.cursor_image.clone().expect("initial brush cursor"); + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_y: -45.0, rotation: 90.0, ..Default::default() })); + h.run_steps(3); + let turned = h.output().platform_output.cursor_image.clone().expect("turned brush cursor"); + save_pen_cursor_image(&format!("{control}-initial"), &initial); + save_pen_cursor_image(&format!("{control}-turned"), &turned); + let pen = h.state().stylus.sample().expect("simulated pen reaches the canvas"); + assert_eq!((pen.tilt_y, pen.rotation), (-45.0, 90.0)); + let point = photocraft_paint::StrokePoint { tilt_y: pen.tilt_y, rotation: pen.rotation, ..Default::default() }; + let step = photocraft_paint::dynamics::StepInput { point, direction: 0.0, initial_direction: 0.0, step: 0 }; + let mut dabs = Vec::new(); + photocraft_paint::dynamics::DabBuilder::new(&h.state().session.tools.brush).build(&step, &mut dabs); + assert_eq!(dabs.len(), 1); + assert!((dabs[0].angle.to_degrees() - 90.0).abs() < 0.001, "painted dab turns: {control}"); + assert_ne!(initial.rgba, turned.rgba, "cursor must follow pen orientation: {control}"); + // The dynamically rotated cursor must match the same tip set to that angle directly. + h.state_mut().session.tools.brush.shape_dynamics.enabled = false; + h.state_mut().session.tools.brush.angle = 90.0; + h.run_steps(3); + let reference = h.output().platform_output.cursor_image.clone().expect("static rotated reference"); + assert_eq!(turned.size, reference.size); + assert_eq!(turned.hotspot, reference.hotspot); + assert_eq!(turned.rgba, reference.rgba, "dynamic cursor matches the dab angle: {control}"); + } +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn round_cursor_shows_brush_projection_6350() { + let mut h = harness(); + h.state_mut().session.tools.brush.shape_dynamics.enabled = true; + h.state_mut().session.tools.brush.shape_dynamics.brush_projection = true; + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample::default())); + h.run_steps(3); + let upright = h.output().platform_output.cursor_image.clone().unwrap(); + h.state_mut().stylus.feed.set(Some(crate::stylus::PenSample { tilt_x: 60.0, ..Default::default() })); + h.run_steps(3); + let tilted = h.output().platform_output.cursor_image.clone().unwrap(); + save_pen_cursor_image("projection-upright", &upright); + save_pen_cursor_image("projection-tilted", &tilted); + assert_ne!(upright.rgba, tilted.rgba, "a projected round brush must not keep a circular cursor"); + h.state_mut().stylus.feed.set(None); + h.run_steps(3); + assert_eq!(upright.rgba, h.output().platform_output.cursor_image.clone().unwrap().rgba, "mouse restores the unprojected tip"); +} + +#[cfg(not(target_arch = "wasm32"))] +fn save_pen_cursor_image(name: &str, cursor: &egui::CustomCursorImage) { + let Some(dir) = std::env::var_os("PHOTOCRAFT_CURSOR_OUTPUT") else { return }; + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + let image = + photocraft_codecs::Image::from_u8(u32::from(cursor.size[0]), u32::from(cursor.size[1]), photocraft_codecs::ChannelLayout::Rgba, cursor.rgba.to_vec()) + .unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + std::fs::write(dir.join(format!("{name}.png")), png).unwrap(); +} + #[test] #[cfg(not(target_arch = "wasm32"))] fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_changes() { diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index 2e66ae07c1..ce6cbb9e49 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -168,53 +168,24 @@ pub fn draw_badge(painter: &egui::Painter, p: Pos2, b: Badge, centred: bool) { } } -/// Dash length of the marching ants, in screen points. -pub const DASH: f32 = 4.0; - -/// Marching ants along a polyline: a solid white line under black dashes, the dashes shifted by -/// `phase` (animate it with time). Visible on any image, like Photoshop's selection edges. -pub fn ants(points: &[Pos2], closed: bool, phase: f32) -> Vec { - let Some(&first) = points.first().filter(|_| points.len() >= 2) else { return Vec::new() }; - let mut pts = points.to_vec(); - if closed { - pts.push(first); - } - let mut out = vec![Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))]; - let black = Stroke::new(1.0, Color32::BLACK); - // Arc length so dashes run continuously across vertices. - let mut along = -phase.rem_euclid(DASH * 2.0); - for w in pts.windows(2) { - let (a, b) = (w[0], w[1]); - let len = a.distance(b); - if len < 1e-3 { - continue; - } - let dir = (b - a) / len; - // Position of this segment's start within the dash period. - let mut t = -(along.rem_euclid(DASH * 2.0)); - while t < len { - let (s0, s1) = (t.max(0.0), (t + DASH).min(len)); - if s1 > s0 { - out.push(Shape::line_segment([a + dir * s0, a + dir * s1], black)); - } - t += DASH * 2.0; - } - along += len; - } - out -} - -/// Marching-ants phase for the frame time `time` (s), and keep the animation running. -pub fn ants_phase(ctx: &egui::Context) -> f32 { - let time = ctx.input(|i| i.time); - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - ((time * 10.0) % (DASH as f64 * 2.0)) as f32 -} - -/// Draw animated marching ants along `points`. +/// Draw animated marching ants along `points`: the same ants as a selection's (`crate::ants`), +/// visible on any image, like Photoshop's selection edges. pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) { - let phase = ants_phase(painter.ctx()); - painter.extend(ants(points, closed, phase)); + crate::ants::draw_polyline(painter, points, closed); +} + +/// A selection outline still being drawn (Lasso, Polygonal Lasso, Magnetic Lasso): a solid line, +/// as Photoshop draws it until the selection is made (#7495). Marching ants are for selections +/// that exist. A thin dark line on a soft light edge: Photoshop's look on light pixels, and still +/// visible on dark ones. +pub fn solid_path(points: &[Pos2], closed: bool) -> Vec { + let make = |s: Stroke| if closed { Shape::closed_line(points.to_vec(), s) } else { Shape::line(points.to_vec(), s) }; + vec![make(Stroke::new(3.0, Color32::from_white_alpha(140))), make(Stroke::new(1.0, Color32::BLACK))] +} + +/// Draw [`solid_path`]. +pub fn draw_solid_path(painter: &egui::Painter, points: &[Pos2], closed: bool) { + painter.extend(solid_path(points, closed)); } /// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth). @@ -294,24 +265,21 @@ mod tests { assert!(badge_glyph(Badge::Intersect, c, 3.0).iter().all(|[a, b]| a.x != b.x && a.y != b.y)); } + /// #7495: an outline still being drawn is one unbroken line (no dashes, nothing marching), + /// black over a light edge. #[test] - fn ants_use_two_contrasting_colours_and_march() { + fn solid_path_is_one_unbroken_dark_line_on_a_light_edge() { + let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; + for closed in [false, true] { + let shapes = solid_path(&pts, closed); + assert!(shapes.iter().all(|s| matches!(s, Shape::Path(p) if p.closed == closed && p.points.len() == 3)), "{shapes:?}"); + assert_eq!(strokes(&shapes), vec![Color32::from_white_alpha(140), Color32::BLACK], "the dark line on top"); + } + } + + #[test] + fn shape_previews_contrast_and_ellipses_close() { let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; - let a = ants(&pts, true, 0.0); - let cols = strokes(&a); - assert!(cols.contains(&Color32::WHITE) && cols.contains(&Color32::BLACK), "{cols:?}"); - // Roughly half the length is black dashes: (40 + 30 + 50) / 8 ≈ 15 dashes. - let dashes = cols.iter().filter(|c| **c == Color32::BLACK).count(); - assert!((13..=18).contains(&dashes), "{dashes}"); - // The phase moves the dashes (the ants march). - let first = |s: &[Shape]| { - s.iter().find_map(|s| match s { - Shape::LineSegment { points, stroke } if stroke.color == Color32::BLACK => Some(points[0]), - _ => None, - }) - }; - assert_ne!(first(&a), first(&ants(&pts, true, 2.0))); - assert!(ants(&pts[..1], false, 0.0).is_empty()); // Shape previews: the accent path over a dark halo. let s = contrast_path(pts.to_vec(), true, Color32::from_rgb(0, 120, 255)); let cols = strokes(&s); diff --git a/crates/ui-egui/src/tool_memory.rs b/crates/ui-egui/src/tool_memory.rs index 4c0f61b817..0d9e4f7fd4 100644 --- a/crates/ui-egui/src/tool_memory.rs +++ b/crates/ui-egui/src/tool_memory.rs @@ -4,13 +4,23 @@ //! so a default changed in a later release still reaches users who never touched that option. //! Saved once the pointer is up (not on every slider step), restored at launch. A hand-edited or //! old file can't break the options: an unknown key is ignored and a field that doesn't read back -//! (wrong type, a number too large for its field) keeps its default. +//! (wrong type, a number too large for its field) keeps its default, and numbers are clamped to +//! the ranges their options-bar fields allow ([`sanitize`]). +//! +//! Each painting tool's own brush (`UiState::tool_brushes`, swapped by +//! `paint_mouse::sync_tool_brush`) is kept the same way in `Preferences::tool_brushes` (#6212), +//! with the tool that owns the session brush (that brush itself is `Preferences::last_brush`). +//! Reset Tool and Reset All Tools change both, so the next save forgets what they reset. -use serde_json::{Map, Value}; +use photocraft_engine::BrushSettings; +use serde_json::{Map, Value, json}; use crate::PhotocraftApp; use crate::state::{Tool, ToolOptions}; +/// The tool whose brush the session brush is, and the other painting tools' brushes. +pub(crate) type ToolBrushes = (Tool, Vec<(Tool, BrushSettings)>); + /// Explicit choices override temporary pen and held-key tools, even when selecting the same tool. pub(crate) fn select(app: &mut PhotocraftApp, tool: Tool) { app.stylus.tool_before_eraser = None; @@ -58,6 +68,7 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.request_repaint(); } } + persist_brushes(app); if app.prefs_rt.tool_options.as_ref() == Some(&app.ui.tool_options) { return; } @@ -68,9 +79,86 @@ pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Remember the painting tools' own brushes when they change (a compare per frame otherwise: +/// tip bitmaps are shared, so equal brushes compare by pointer). A brush that can't be remembered +/// yet (a sampled tip too big to embed, before the preset store takes it) keeps what was saved. +fn persist_brushes(app: &mut PhotocraftApp) { + let (owner, brushes) = (saved_owner(app), &app.ui.tool_brushes); + if app.prefs_rt.tool_brushes.as_ref().is_some_and(|(o, b)| *o == owner && b == brushes) { + return; + } + let old = app.session.prefs().tool_brushes.get("brushes").and_then(Value::as_object).cloned().unwrap_or_default(); + let mut saved = Map::new(); + for (tool, brush) in brushes { + // The owner's brush is the session brush, remembered as `last_brush`. + if *tool == owner { + continue; + } + let Ok(Value::String(key)) = serde_json::to_value(tool) else { continue }; + if let Some(v) = photocraft_engine::brush_cmds::remembered_settings(brush).or_else(|| old.get(&key).cloned()) { + saved.insert(key, v); + } + } + // Nothing to remember while the Brush owns the session brush and no other tool has its own. + let now = match serde_json::to_value(owner) { + Ok(o) if !saved.is_empty() || owner != Tool::Brush => json!({"owner": o, "brushes": saved}), + _ => Value::Null, + }; + app.prefs_rt.tool_brushes = Some((owner, brushes.clone())); + if app.session.prefs().tool_brushes != now { + app.session.prefs.edit(|p| p.tool_brushes = now); + } +} + +/// The tool whose brush `last_brush` holds: the one that owns the session brush, once +/// `last_brush::persist` has saved that brush. Until then (a sampled tip too big to embed, before +/// the preset store takes it) `last_brush` still holds the previously saved owner's brush, so +/// that owner stands; with none saved it is the Brush, as `toolBrushes` is null then. +fn saved_owner(app: &PhotocraftApp) -> Tool { + let t = &app.session.tools; + if t.brush_rev == t.brush_saved_rev { + return app.ui.brush_tool; + } + app.session.prefs().tool_brushes.get("owner").and_then(|v| serde_json::from_value::(v.clone()).ok()).unwrap_or(Tool::Brush) +} + +/// Restore the painting tools' own brushes and which tool the session brush (`last_brush`) +/// belongs to. Unknown tools, tools without a brush and brushes that don't read back are skipped +/// with a warning: such a tool starts from the current brush, as on a first launch. +fn restore_brushes(app: &mut PhotocraftApp) { + let saved = app.session.prefs().tool_brushes.clone(); + if !saved.is_null() && !saved.is_object() { + log::warn!("Preferences: the remembered tool brushes are not an object; using the defaults"); + } + let tool = |v: Value| serde_json::from_value::(v).ok().filter(|t| crate::paint_mouse::has_brush_picker(*t)); + if let Some(v) = saved.get("owner") { + match tool(v.clone()) { + Some(owner) => app.ui.brush_tool = owner, + None => log::warn!("Preferences: ignoring the remembered brush owner: {v}"), + } + } + let owner = app.ui.brush_tool; + let mut brushes: Vec<(Tool, BrushSettings)> = Vec::new(); + for (k, v) in saved.get("brushes").and_then(Value::as_object).into_iter().flatten() { + let Some(t) = tool(Value::String(k.clone())).filter(|t| *t != owner) else { + log::warn!("Preferences: ignoring the remembered brush of `{k}`"); + continue; + }; + match photocraft_engine::brush_cmds::restored_settings(&app.session, v) { + Some(b) => brushes.push((t, b)), + None => log::warn!("Preferences: ignoring the remembered brush of `{k}`: it doesn't read back"), + } + } + app.ui.tool_brushes = brushes; + app.prefs_rt.tool_brushes = Some((owner, app.ui.tool_brushes.clone())); +} + /// Flush the final choice on normal exit, even if no further frame ran after the tool changed. +/// The brush first, as in a frame, so the saved brush owner matches the saved brush. pub(crate) fn save_on_exit(app: &mut PhotocraftApp) -> Result<(), String> { - persist(app, &egui::Context::default()); + let ctx = egui::Context::default(); + crate::last_brush::persist(app, &ctx); + persist(app, &ctx); crate::prefs_ui::save_preferences(app) } @@ -99,13 +187,64 @@ pub fn restore(app: &mut PhotocraftApp) { cur.insert(k.clone(), old); } } - if let Ok(o) = serde_json::from_value(Value::Object(cur)) { + if let Ok(mut o) = serde_json::from_value(Value::Object(cur)) { + sanitize(&mut o); app.ui.tool_options = o; } } else if !saved.is_null() { log::warn!("Preferences: the remembered tool options are not an object; using the defaults"); } app.prefs_rt.tool_options = Some(app.ui.tool_options.clone()); + restore_brushes(app); +} + +/// Bring remembered options into the ranges their options-bar fields allow, so a hand-edited or +/// damaged file can't start a tool with a value its own controls could never set (a 1e30 px +/// feather, a 0-sided polygon). A NaN (JSON can't hold one, but it costs nothing to rule out) +/// takes the default. +pub(crate) fn sanitize(o: &mut ToolOptions) { + let d = ToolOptions::default(); + let fit = |v: &mut f32, lo: f32, hi: f32, def: f32| *v = if v.is_nan() { def } else { v.clamp(lo, hi) }; + fit(&mut o.tolerance, 0.0, 255.0, d.tolerance); + fit(&mut o.feather, 0.0, 1000.0, d.feather); + fit(&mut o.fill_opacity, 1.0, 100.0, d.fill_opacity); + fit(&mut o.type_size, 0.1, 1296.0, d.type_size); + fit(&mut o.pattern_stamp_scale, 1.0, 1000.0, d.pattern_stamp_scale); + fit(&mut o.pattern_stamp_angle, -180.0, 180.0, d.pattern_stamp_angle); + fit(&mut o.patch_structure, 1.0, 7.0, d.patch_structure); + fit(&mut o.patch_color, 0.0, 10.0, d.patch_color); + fit(&mut o.cam_structure, 1.0, 7.0, d.cam_structure); + fit(&mut o.cam_color, 0.0, 10.0, d.cam_color); + fit(&mut o.exposure, 1.0, 100.0, d.exposure); + fit(&mut o.strength, 1.0, 100.0, d.strength); + fit(&mut o.stroke_width, 0.0, 288.0, d.stroke_width); + fit(&mut o.corner_radius, 0.0, 10000.0, d.corner_radius); + fit(&mut o.line_weight, 1.0, 1000.0, d.line_weight); + fit(&mut o.curve_fit, 0.5, 10.0, d.curve_fit); + // The marquee's Width and Height are a ratio or a size in px, as its Style says. + let (lo, hi) = if o.marquee_style == "fixedSize" { (1.0, 300_000.0) } else { (0.001, 999.0) }; + fit(&mut o.marquee_width, lo, hi, d.marquee_width); + fit(&mut o.marquee_height, lo, hi, d.marquee_height); + fit(&mut o.crop_custom_ratio_width, 0.0, f32::MAX, d.crop_custom_ratio_width); + fit(&mut o.crop_custom_ratio_height, 0.0, f32::MAX, d.crop_custom_ratio_height); + fit(&mut o.magic_eraser_opacity, 1.0, 100.0, d.magic_eraser_opacity); + fit(&mut o.bg_tolerance, 0.0, 100.0, d.bg_tolerance); + fit(&mut o.cr_tolerance, 0.0, 100.0, d.cr_tolerance); + fit(&mut o.magnetic_width, 1.0, 256.0, d.magnetic_width); + fit(&mut o.magnetic_contrast, 1.0, 100.0, d.magnetic_contrast); + fit(&mut o.magnetic_frequency, 0.0, 100.0, d.magnetic_frequency); + fit(&mut o.red_eye_pupil_size, 1.0, 100.0, d.red_eye_pupil_size); + fit(&mut o.red_eye_darken, 0.0, 100.0, d.red_eye_darken); + o.polygon_sides = o.polygon_sides.clamp(3, 100); + if o.crop_overlay_orientation > 3 { + o.crop_overlay_orientation = d.crop_overlay_orientation; + } + if !photocraft_engine::sample_cmds::SAMPLE_SIZES.contains(&o.eyedropper_size) { + o.eyedropper_size = d.eyedropper_size; + } + if !o.shape_stroke.valid() { + o.shape_stroke = d.shape_stroke; + } } /// Does `opts` deserialize, with field `k` coming back as a value? A number out of an `f32`'s @@ -124,3 +263,50 @@ fn nulls(v: &Value) -> usize { _ => 0, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_tool_and_non_default_options_roundtrip() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Move; + app.ui.tool_options.tolerance = 42.5; + persist(&mut app, &egui::Context::default()); + + let saved = app.session.prefs(); + assert_eq!(saved.selected_tool, serde_json::to_value(Tool::Move).unwrap()); + assert_eq!(saved.tool_options["tolerance"], Value::from(42.5)); + assert!(saved.tool_options.get("contiguous").is_none(), "unchanged defaults are not written"); + + let mut restored = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + restored.session.prefs.edit(|p| { + p.selected_tool = saved.selected_tool.clone(); + p.tool_options = saved.tool_options.clone(); + }); + restore(&mut restored); + assert_eq!(restored.ui.tool, Tool::Move); + assert_eq!(restored.ui.tool_options.tolerance, 42.5); + } + + #[test] + fn restore_ignores_unknown_and_malformed_tool_options() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let original_tool = app.ui.tool; + let default_contiguous = ToolOptions::default().contiguous; + app.session.prefs.edit(|p| { + p.selected_tool = Value::from("not-a-tool"); + p.tool_options = serde_json::json!({ + "tolerance": 42.5, + "contiguous": "not a boolean", + "future_option": true + }); + }); + + restore(&mut app); + assert_eq!(app.ui.tool, original_tool, "an invalid selected tool leaves the current tool intact"); + assert_eq!(app.ui.tool_options.tolerance, 42.5, "valid fields still restore"); + assert_eq!(app.ui.tool_options.contiguous, default_contiguous, "a malformed field falls back to its default"); + } +} diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 3c154d3a45..d0399ab4bc 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -157,7 +157,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { } match tool { Tool::Move => reset!(move_auto_select, move_target, move_show_transform), - Tool::RectMarquee | Tool::EllipseMarquee => reset!(feather, anti_alias, marquee_style, marquee_width, marquee_height), + Tool::RectMarquee | Tool::EllipseMarquee => reset!(feather, anti_alias, marquee_style, marquee_width, marquee_height, marquee_unit), // The single-pixel marquees are never anti-aliased: Feather is their only option. Tool::SingleRowMarquee | Tool::SingleColumnMarquee => reset!(feather), Tool::Lasso | Tool::PolygonLasso => reset!(feather, anti_alias), @@ -191,7 +191,9 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous), - Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), + Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => { + reset!(type_font, type_style, type_size, type_aa, type_align, type_direction, type_style_defaults) + } Tool::Zoom => reset!(zoom_scrubby), Tool::SpotHealing => reset!(spot_type), Tool::Healing => reset!(clone_aligned, clone_sample), diff --git a/crates/ui-egui/src/touch_twins.rs b/crates/ui-egui/src/touch_twins.rs new file mode 100644 index 0000000000..aee4aa33d3 --- /dev/null +++ b/crates/ui-egui/src/touch_twins.rs @@ -0,0 +1,118 @@ +//! One tap, one press (#2095). In the browser eframe turns a finger or pen tap into two presses +//! and two releases: one pair from Pointer Events (`pointerdown` / `pointerup`) and one from Touch +//! Events (`touchstart` / `touchend`), which mobile browsers fire for the same contact. A menu +//! title or press menu toggles on the press, so the second press closed the menu the first had +//! just opened. [`drop_twins`] keeps the first of each and drops its twin. + +use egui::{Event, PointerButton, RawInput}; + +/// Longest gap, in seconds, between a press and its twin from the other event family (both come +/// from one contact, normally within a frame or two). +const TWIN_WINDOW: f64 = 0.5; + +/// Drops a primary press that arrives while the primary button is already held (pressed within +/// [`TWIN_WINDOW`]) and a primary release that arrives while it is already up. A press while held +/// for longer still passes, so a release the browser never sent can't lock the pointer down. +/// Call from `raw_input_hook`, before the frame runs. +pub fn drop_twins(ctx: &egui::Context, raw: &mut RawInput) { + let (mut down, mut pressed_at, last) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_start_time(), i.time)); + let now = raw.time.unwrap_or(last + f64::from(raw.predicted_dt)); + raw.events.retain(|e| { + let Event::PointerButton { button: PointerButton::Primary, pressed, .. } = e else { return true }; + if *pressed != down { + down = *pressed; + pressed_at = pressed.then_some(now); + return true; + } + // A press while held is a twin only while fresh; a release while up always is. + *pressed && !pressed_at.is_some_and(|t| now - t <= TWIN_WINDOW) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Modifiers, Pos2, pos2}; + + fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } + } + + fn touch(pos: Pos2, phase: egui::TouchPhase) -> Event { + Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(7), phase, pos, force: None } + } + + /// One browser tap as eframe delivers it: `pointerdown` runs a frame of its own, then + /// `touchstart`, `pointerup` (again a frame of its own) and `touchend`. + fn tap_frames(pos: Pos2) -> [Vec; 4] { + [ + vec![button(pos, true)], + vec![button(pos, true), touch(pos, egui::TouchPhase::Start)], + vec![button(pos, false)], + vec![button(pos, false), Event::PointerGone, touch(pos, egui::TouchPhase::End)], + ] + } + + #[test] + fn a_tap_opens_a_menu_title_every_time() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + }, + app, + ); + crate::PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + let file = harness.get_by_label("File").rect().center(); + let ctx = harness.ctx.clone(); + let tap = |harness: &mut Harness<'_, crate::PhotocraftApp>, dedupe: bool| { + for events in tap_frames(file) { + harness.input_mut().events = events; + if dedupe { + drop_twins(&ctx, harness.input_mut()); + } + harness.step(); + } + harness.run_steps(2); + harness.query_by_label_contains("Open…").is_some() + }; + // Without the hook the second press closes the menu the first opened. + assert!(!tap(&mut harness, false), "the cause: the twin press closes the menu"); + assert!(tap(&mut harness, true), "the first tap opens File"); + assert!(!tap(&mut harness, true), "a second tap closes it"); + assert!(tap(&mut harness, true), "a third tap opens it again"); + } + + /// A fast double tap delivered in one frame: each event changes the button's state, so none is + /// a twin. + #[test] + fn a_press_release_press_in_one_frame_keeps_all_three() { + let ctx = egui::Context::default(); + let p = pos2(10.0, 10.0); + let mut raw = RawInput { time: Some(1.0), events: vec![button(p, true), button(p, false), button(p, true)], ..Default::default() }; + drop_twins(&ctx, &mut raw); + assert_eq!(raw.events.len(), 3); + } + + #[test] + fn a_press_held_for_long_still_passes() { + let ctx = egui::Context::default(); + let p = pos2(10.0, 10.0); + let frame = |events: Vec, time: f64| { + let mut raw = RawInput { time: Some(time), events, ..Default::default() }; + drop_twins(&ctx, &mut raw); + let kept = raw.events.clone(); + ctx.run_ui(raw, |_| {}).textures_delta.clear(); + kept + }; + assert_eq!(frame(vec![button(p, true)], 1.0).len(), 1); + assert!(frame(vec![button(p, true)], 1.1).is_empty(), "the twin press"); + // The release never came: a later press is a new one, not a twin. + assert_eq!(frame(vec![button(p, true)], 3.0).len(), 1); + assert_eq!(frame(vec![button(p, false), button(p, false)], 3.1).len(), 1, "one release per press"); + assert!(frame(vec![button(p, false)], 3.2).is_empty(), "a release while up"); + } +} diff --git a/crates/ui-egui/src/transform_fx.rs b/crates/ui-egui/src/transform_fx.rs index 3b56225efa..60fc45d993 100644 --- a/crates/ui-egui/src/transform_fx.rs +++ b/crates/ui-egui/src/transform_fx.rs @@ -348,6 +348,7 @@ impl Frame<'_> { #[derive(Clone, PartialEq)] struct Key { revision: u64, + display_key: u64, quad: [u64; 8], plan: Plan, level: u32, @@ -389,12 +390,18 @@ impl StyledPreview { /// The composite for `frame` and the document rectangle it covers (x0, y0, x1, y1), /// compositing it again if anything it depends on changed. `None` when there is nothing to /// draw this way: the caller falls back to the plain texels. - pub fn texture(&self, ctx: &egui::Context, frame: &Frame) -> Option<(TextureId, [f32; 4])> { + pub fn texture( + &self, + ctx: &egui::Context, + frame: &Frame, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, + ) -> Option<(TextureId, [f32; 4])> { let (plan, work, level) = frame.plan()?; let crisp = plan.scale >= 1.0 && frame.zoom >= CRISP_ZOOM; let q = frame.quad.map(|q| q.map(f64::to_bits)); let key = Key { revision: frame.revision, + display_key: display.map_or(0, |d| d.key), quad: [q[0][0], q[0][1], q[1][0], q[1][1], q[2][0], q[2][1], q[3][0], q[3][1]], plan, level, @@ -407,7 +414,11 @@ impl StyledPreview { } let out = frame.compose(&plan, &work, level)?; let options = if crisp { TextureOptions::NEAREST } else { TextureOptions::LINEAR }; - let tex = ctx.load_texture(self.name.clone(), image(&out), options); + let pixels = match display { + Some(d) if !d.is_identity() => crate::canvas::display_image(Some(d), &out), + _ => image(&out), + }; + let tex = ctx.load_texture(self.name.clone(), pixels, options); let (id, rect) = (tex.id(), plan.doc_rect()); state.shown = Some(Shown { key, tex, rect }); state.builds += 1; @@ -429,6 +440,7 @@ mod tests { size, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + overprint: false, }) } diff --git a/crates/ui-egui/src/transform_fx_tests.rs b/crates/ui-egui/src/transform_fx_tests.rs index 0eb860dadd..23e9d471a6 100644 --- a/crates/ui-egui/src/transform_fx_tests.rs +++ b/crates/ui-egui/src/transform_fx_tests.rs @@ -27,6 +27,33 @@ fn begin(app: &mut PhotocraftApp) { crate::transform_tool::begin(app, &egui::Context::default()).unwrap(); } +#[test] +fn styled_texture_tracks_monitor_changes_without_recoloring_effect_inputs() { + let mut app = app("rgb", 16); + let ctx = egui::Context::default(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let raw = composite(&app, 1.0).unwrap().0; + let mut previous = None; + for monitor in ["srgb", "display-p3", "srgb"] { + app.session.color.settings.monitor_profile = monitor.into(); + let display = app.session.color.canvas_display(&app.session.active().unwrap().doc).unwrap(); + let expected = crate::canvas::display_image(Some(&display), &raw); + let (styled, frame) = crate::transform_tool::styled_frame(&app, 1.0, false).unwrap(); + let (id, _) = styled.texture(&ctx, &frame, Some(&display)).unwrap(); + assert_ne!(Some(id), previous, "monitor change invalidates the texture"); + let builds = styled.builds(); + assert_eq!(styled.texture(&ctx, &frame, Some(&display)).unwrap().0, id); + assert_eq!(styled.builds(), builds, "unchanged display reuses the composite"); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(shown) = &delta.set.get(&id).unwrap().last().unwrap().image; + assert_eq!(shown.pixels, expected.pixels, "monitor={monitor}"); + delta.clear(); + assert_eq!(composite(&app, 1.0).unwrap().0.px, raw.px, "effects still use document colors"); + previous = Some(id); + } +} + /// What the box draws for the styled layer at `zoom`: `None` when it draws the bare pixels. fn composite(app: &PhotocraftApp, zoom: f32) -> Option<(Buffer, Plan)> { let (_, frame) = crate::transform_tool::styled_frame(app, zoom, false)?; diff --git a/crates/ui-egui/src/transform_tex.rs b/crates/ui-egui/src/transform_tex.rs index bd84704542..ae67b2e2c5 100644 --- a/crates/ui-egui/src/transform_tex.rs +++ b/crates/ui-egui/src/transform_tex.rs @@ -16,10 +16,12 @@ use std::sync::{Arc, Mutex, PoisonError}; use egui::{Color32, ColorImage, TextureHandle, TextureId, TextureOptions}; +use photocraft_engine::display_color::CanvasDisplay; /// One uploaded texture: its mip level, filter and the uv extent covering the source. struct Level { level: u32, + display_key: u64, nearest: bool, tex: TextureHandle, uv: [f32; 2], @@ -43,7 +45,12 @@ impl PreviewTextures { let image = Arc::new(image); let tex = ctx.load_texture(format!("{name}-0-linear"), Arc::clone(&image), TextureOptions::LINEAR); let base_uv = [base_uv[0].clamp(0.0, 1.0), base_uv[1].clamp(0.0, 1.0)]; - Self { name, images: Mutex::new(vec![image]), base_uv, uploaded: Mutex::new(vec![Level { level: 0, nearest: false, tex, uv: base_uv }]) } + Self { + name, + images: Mutex::new(vec![image]), + base_uv, + uploaded: Mutex::new(vec![Level { level: 0, display_key: 0, nearest: false, tex, uv: base_uv }]), + } } /// Size of the full-resolution level in texels. @@ -72,20 +79,38 @@ impl PreviewTextures { } /// Texture and uv extent to draw with (uploading a level / filter on first use). + #[cfg(test)] pub fn pick(&self, ctx: &egui::Context, zoom: f32, scale: f32, nearest_interpolation: bool) -> (TextureId, [f32; 2]) { + self.pick_display(ctx, zoom, scale, nearest_interpolation, None) + } + + /// Convert only the displayed upload: effects still read the raw mip levels. + pub fn pick_display( + &self, + ctx: &egui::Context, + zoom: f32, + scale: f32, + nearest_interpolation: bool, + display: Option<&CanvasDisplay>, + ) -> (TextureId, [f32; 2]) { let (level, nearest) = Self::choose(zoom, scale, nearest_interpolation); + let display = display.filter(|d| !d.is_identity()); + let display_key = display.map_or(0, |d| d.key); let mut up = self.uploaded.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest) { + if let Some(l) = up.iter().find(|l| l.level == level && l.nearest == nearest && l.display_key == display_key) { return (l.tex.id(), l.uv); } let Some((img, uv)) = self.level_image(level) else { // Can't build it (shouldn't happen): fall back to the first upload. return up.first().map_or((TextureId::default(), [1.0, 1.0]), |l| (l.tex.id(), l.uv)); }; + let img = display.map_or_else(|| Arc::clone(&img), |d| Arc::new(display_image(&img, d))); let opts = if nearest { TextureOptions::NEAREST } else { TextureOptions::LINEAR }; let tex = ctx.load_texture(format!("{}-{level}-{}", self.name, if nearest { "nearest" } else { "linear" }), img, opts); let id = tex.id(); - up.push(Level { level, nearest, tex, uv }); + // Profile changes must not accumulate another full set of GPU textures. + up.retain(|l| l.display_key == display_key); + up.push(Level { level, display_key, nearest, tex, uv }); (id, uv) } @@ -110,6 +135,28 @@ impl PreviewTextures { } } +/// Premultiplied preview texels -> straight document values -> monitor values. +/// Bound the temporary float buffer independently of the texture size. +fn display_image(image: &ColorImage, display: &CanvasDisplay) -> ColorImage { + let mut out = image.clone(); + let convert = |pixels: &mut [Color32]| { + let px = pixels.iter().map(|p| p.to_srgba_unmultiplied().map(|c| f32::from(c) / 255.0)).collect(); + let buffer = photocraft_compose::Buffer { rect: photocraft_geom::Rect::new(0, 0, pixels.len() as i32, 1), px }; + let converted = display.to_rgba8(&buffer); + for (p, rgba) in pixels.iter_mut().zip(converted.pixels.as_chunks::<4>().0) { + *p = Color32::from_rgba_unmultiplied(rgba[0], rgba[1], rgba[2], rgba[3]); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.pixels.par_chunks_mut(4096).for_each(convert); + } + #[cfg(target_arch = "wasm32")] + out.pixels.chunks_mut(4096).for_each(convert); + out +} + /// 2×2 box filter of a premultiplied image (odd edges repeat the last row/column). fn halve(img: &ColorImage) -> ColorImage { let [w, h] = img.size; @@ -198,6 +245,67 @@ pub fn read_surface(surf: &photocraft_raster::Surface, mask: Option<&PreviewMask #[cfg(test)] mod tests { + #[test] + #[ignore = "reports CPU preparation cost; run explicitly for profile-preview changes"] + fn transform_profile_upload_cost() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", serde_json::json!({"width": 6000, "height": 4000})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + for monitor in ["raw", "srgb", "display-p3"] { + session.color.settings.monitor_profile = if monitor == "raw" { "srgb" } else { monitor }.into(); + let display = session.color.canvas_display(&doc).unwrap(); + let ctx = egui::Context::default(); + let image = ColorImage::new([6000, 4000], vec![Color32::from_rgb(140, 89, 51); 6000 * 4000]); + let display = (monitor != "raw").then_some(display.as_ref()); + let started = std::time::Instant::now(); + let textures = PreviewTextures::new(&ctx, "profile-cost".into(), image, [1.0, 1.0]); + let texture = textures.pick_display(&ctx, 1.0, 1.0, false, display).0; + let prepare = started.elapsed(); + ctx.tex_manager().write().take_delta().clear(); + let started = std::time::Instant::now(); + for _ in 0..1000 { + assert_eq!(std::hint::black_box(textures.pick_display(&ctx, 1.0, 1.0, false, display)).0, texture); + } + let cached = started.elapsed(); + let mut delta = ctx.tex_manager().write().take_delta(); + assert!(delta.set.is_empty(), "cached dragging must not upload or reconvert pixels"); + delta.clear(); + eprintln!( + "profile={monitor}, pixels=24000000, first_prepare_ms={:.3}, cached_1000_us={:.3}; CPU preparation only, not GPU completion", + prepare.as_secs_f64() * 1000.0, + cached.as_secs_f64() * 1_000_000.0 + ); + } + } + + #[test] + fn display_upload_changes_with_monitor_but_keeps_raw_alpha_and_mips() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", serde_json::json!({"width": 2, "height": 1})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + session.color.settings.monitor_profile = "srgb".into(); + let srgb = session.color.canvas_display(&doc).unwrap(); + session.color.settings.monitor_profile = "display-p3".into(); + let p3 = session.color.canvas_display(&doc).unwrap(); + assert!(srgb.is_identity()); + assert!(!p3.is_identity()); + let raw = ColorImage::new([2, 1], vec![Color32::from_rgba_unmultiplied(140, 89, 51, 128), Color32::TRANSPARENT]); + let converted = display_image(&raw, &p3); + assert_eq!(converted.pixels[0].a(), 128); + assert_eq!(converted.pixels[1], Color32::TRANSPARENT); + assert_ne!(converted.pixels[0], raw.pixels[0]); + let ctx = egui::Context::default(); + let textures = PreviewTextures::new(&ctx, "profile-test".into(), raw.clone(), [1.0, 1.0]); + let first = textures.pick_display(&ctx, 1.0, 1.0, false, Some(&srgb)).0; + let second = textures.pick_display(&ctx, 1.0, 1.0, false, Some(&p3)).0; + assert_ne!(first, second); + assert_eq!(textures.pick_display(&ctx, 1.0, 1.0, false, Some(&p3)).0, second); + assert_eq!(textures.level_image(0).unwrap().0.pixels, raw.pixels); + assert_eq!(textures.level_image(1).unwrap().0.pixels, halve(&raw).pixels); + assert_eq!(textures.uploaded.lock().unwrap().len(), 1, "old profile uploads are released"); + ctx.tex_manager().write().take_delta().clear(); + } + use super::*; #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 7ae223029d..d0eb50da55 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -412,12 +412,16 @@ fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) { /// After the transform: the step that made the layer and the transform become one history step, /// the transform's for a copy and Place Embedded for a placed file. -fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { +fn fold_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId, made: MadeLayer) { // A background commit lands in its own document, which need not be the active one by then. let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return }; let active = app.session.active_index(); app.session.set_active(index); - if let Some(st) = app.session.active_mut() { + // Only when the step before the transform made the layer: a step taken while the box was + // open (New Layer, which also ends the box, #5667) stays its own, as does the made layer's. + if let Some(st) = app.session.active_mut() + && st.history.past_len().checked_sub(2).and_then(|i| st.history.state(i)).is_some_and(|before| before.layer(layer).is_none()) + { st.history.purge_last(); if made == MadeLayer::Place { st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED); @@ -890,9 +894,9 @@ impl Above { } /// Draw the composite over the moving pixels. - fn draw(&self, painter: &egui::Painter, xf: &ViewXform) { + fn draw(&self, painter: &egui::Painter, xf: &ViewXform, display: Option<&photocraft_engine::display_color::CanvasDisplay>) { let Some((texture, r)) = &self.shown else { return }; - let (tex, uv) = texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, false); + let (tex, uv) = texture.pick_display(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, false, display); let mut mesh = egui::Mesh::with_texture(tex); let corners = [(r.x0, r.y0, 0.0, 0.0), (r.x1, r.y0, uv[0], 0.0), (r.x1, r.y1, uv[0], uv[1]), (r.x0, r.y1, 0.0, uv[1])]; for (x, y, u, v) in corners { @@ -983,7 +987,14 @@ fn show_only(layers: &mut [photocraft_doc::Layer], keep: &[LayerId]) { continue; } match &mut l.content { - LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => show_only(&mut g.children, keep), + LayerContent::Group(g) if g.children.iter().any(|c| keep.iter().any(|k| contains(c, *k))) => { + // This ancestor only clips the covering children. Its background is already + // on the base canvas; drawing it again would cover the moving layer in white. + if let Some(board) = &mut g.artboard { + board.background = photocraft_doc::ArtboardBackground::Transparent; + } + show_only(&mut g.children, keep); + } _ => l.visible = false, } } @@ -1002,6 +1013,10 @@ pub(crate) fn solo_layer(layers: &mut [photocraft_doc::Layer], id: LayerId) { match &mut l.content { LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id)) => { l.clipped = false; + // Isolate the child, not the artboard's paper. Keep the board for clipping. + if let Some(board) = &mut g.artboard { + board.background = photocraft_doc::ArtboardBackground::Transparent; + } solo_layer(&mut g.children, id); } _ => l.visible = false, @@ -1013,8 +1028,9 @@ fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } -/// Undo and Redo while the commit runs in the background. +/// Undo and Redo while the commit runs in the background (Free Transform, or Warp). const STILL_APPLYING: &str = "Free Transform is still being applied"; +const STILL_WARPING: &str = "Warp is still being applied"; pub fn commit(app: &mut PhotocraftApp) { if committing(app).is_some() { @@ -1103,7 +1119,7 @@ pub fn commit(app: &mut PhotocraftApp) { fn finish(app: &mut PhotocraftApp, document: Option, layer: LayerId, made: Option, landed: bool) { let (Some(made), Some(document)) = (made, document) else { return }; if landed { - fold_made(app, document, made); + fold_made(app, document, layer, made); } else { take_back_made(app, document, layer); } @@ -1121,10 +1137,14 @@ pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) { return; } let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return }; - // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline. + // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline, + // unless picking another tool or layer started it: then the box ends, as Esc would. if let JobOutcome::Failed(msg) = &e.outcome { pv.committing = None; keep_open(app, t, Some(pv), msg.clone()); + if switched_away(app) { + cancel(app); + } return; } finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_))); @@ -1153,7 +1173,6 @@ pub fn end_if_left(app: &mut PhotocraftApp) { let layer = LayerId(t.layer); let made = t.made; - let selection = t.selection; let preview_doc_id = app.transform_preview.as_ref().map(|pv| pv.doc.id); let switched_document = preview_doc_id.is_some_and(|id| id != st.doc.id); @@ -1167,17 +1186,25 @@ pub fn end_if_left(app: &mut PhotocraftApp) { app.transform_preview = None; } else if switched_document || st.doc.layer(layer).is_none() { cancel(app); - } else if app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) - || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) - { + } else if switched_away(app) { commit(app); // Picking another tool or layer can't leave a refused transform open: it ends, as Esc would. - if app.ui.transform.is_some() { + // A commit still running in the background isn't refused: cancelling it would throw the + // transform away (and a dropped file with it, #5665). It ends the session when it lands, + // or ends it as Esc would if it is refused then (`on_job_event`). + if app.ui.transform.is_some() && committing(app).is_none() { cancel(app); } } } +/// Another tool picked, or (outside Transform Selection) another layer selected, since the box +/// opened: the session ends ([`end_if_left`], #1400). +fn switched_away(app: &PhotocraftApp) -> bool { + let (Some(t), Some(st)) = (&app.ui.transform, app.session.active()) else { return false }; + app.transform_preview.as_ref().is_some_and(|pv| pv.tool != app.ui.tool) || (!t.selection && st.active_layer.is_some_and(|active| active.0 != t.layer)) +} + /// Edits made while the box is open show in the preview at once, not only after the commit /// (#1384): the moving layer's opacity, fill and visibility tint the moving pixels, and the /// document under them (other layers, and the part of the layer a selection leaves behind) is @@ -1316,7 +1343,7 @@ fn box_command(id: &str) -> bool { pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { let warping = app.ui.transform.as_ref()?.warp.is_some(); if committing(app).is_some() { - return box_command(id).then(|| Err(STILL_APPLYING.into())); + return box_command(id).then(|| Err(if warping { STILL_WARPING } else { STILL_APPLYING }.into())); } match id { "edit.undo" => Some(Ok(step(app, false))), @@ -1835,6 +1862,43 @@ pub(crate) fn apply_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mods } } +fn anchor_uv(anchor: usize) -> (f64, f64) { + match anchor { + 0 => (0.0, 0.0), + 1 => (0.5, 0.0), + 2 => (1.0, 0.0), + 3 => (0.0, 0.5), + 4 => (0.5, 0.5), + 5 => (1.0, 0.5), + 6 => (0.0, 1.0), + 7 => (0.5, 1.0), + 8 => (1.0, 1.0), + _ => (0.5, 0.5), + } +} + +fn point_on_quad(quad: [[f64; 2]; 4], anchor: usize) -> [f64; 2] { + let (u, v) = anchor_uv(anchor); + if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], quad) { + let (x, y) = h.apply(u, v); + if x.is_finite() && y.is_finite() { + return [x, y]; + } + } + match anchor { + 0 => quad[0], + 1 => [(quad[0][0] + quad[1][0]) / 2.0, (quad[0][1] + quad[1][1]) / 2.0], + 2 => quad[1], + 3 => [(quad[0][0] + quad[3][0]) / 2.0, (quad[0][1] + quad[3][1]) / 2.0], + 4 => [(quad[0][0] + quad[1][0] + quad[2][0] + quad[3][0]) / 4.0, (quad[0][1] + quad[1][1] + quad[2][1] + quad[3][1]) / 4.0], + 5 => [(quad[1][0] + quad[2][0]) / 2.0, (quad[1][1] + quad[2][1]) / 2.0], + 6 => quad[3], + 7 => [(quad[2][0] + quad[3][0]) / 2.0, (quad[2][1] + quad[3][1]) / 2.0], + 8 => quad[2], + _ => [(quad[0][0] + quad[1][0] + quad[2][0] + quad[3][0]) / 4.0, (quad[0][1] + quad[1][1] + quad[2][1] + quad[3][1]) / 4.0], + } +} + /// Where the reference point lands when the box goes from `quad0` to `quad`: the same place /// within the box, as in Photoshop, where it is attached to the bounding box (#1329). A box /// that has collapsed leaves it where it was. @@ -2122,7 +2186,7 @@ pub fn cursor(app: &PhotocraftApp, xf: &ViewXform, p: [f64; 2], quick_split: boo // A handle resizes the box away from the opposite handle. let resize = |from: [f64; 2], to: [f64; 2]| crate::tool_cursor::resize([to[0] - from[0], to[1] - from[1]], xf); Some(match h { - Hit::Corner(i) => resize(corner(i + 2), corner(i)), + Hit::Corner(i) => crate::tool_cursor::corner_resize(&t.quad, i, xf), Hit::Edge(i) => resize(edge_mid(i + 2), edge_mid(i)), Hit::Pivot => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, @@ -2145,12 +2209,17 @@ fn front_vertices(h: &Homography, n: usize) -> Vec { /// Photoshop. The box, handles and reference point are [`draw_overlay`], drawn over the guides. pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let (Some(t), Some(pv)) = (&app.ui.transform, &app.transform_preview) else { return }; + let output = crate::monitor_status::view_display(app, painter.ctx()); + let (display, _) = crate::canvas::canvas_display(app, &pv.doc, output); + // Selection tint and lone mask/channel values are UI diagnostics, not document colors. + let display = display.as_deref().filter(|_| !t.selection && t.target.is_none() && t.path.is_none()); if let Some(w) = &t.warp { - draw_warp_content(painter, xf, t, w, pv); + draw_warp_content(painter, xf, t, w, pv, display); return; } let ctx = painter.ctx(); - let styled = styled_frame(app, xf.zoom * ctx.pixels_per_point(), ctx.input(|i| i.pointer.any_down())).and_then(|(s, frame)| s.texture(ctx, &frame)); + let styled = + styled_frame(app, xf.zoom * ctx.pixels_per_point(), ctx.input(|i| i.pointer.any_down())).and_then(|(s, frame)| s.texture(ctx, &frame, display)); if let Some((tex, r)) = styled { let mut mesh = egui::Mesh::with_texture(tex); for (x, y, u, v) in [(r[0], r[1], 0.0, 0.0), (r[2], r[1], 1.0, 0.0), (r[2], r[3], 1.0, 1.0), (r[0], r[3], 0.0, 1.0)] { @@ -2165,8 +2234,8 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform let zoom = xf.zoom * painter.ctx().pixels_per_point(); // Content-Aware Scale shows its latest live result once there is one. let (tex, uv) = match pv.content_aware.as_ref().and_then(|l| l.shown(t)) { - Some((texture, texel)) => texture.pick(painter.ctx(), zoom, texel, false), - None => pv.texture.pick(painter.ctx(), zoom, quad_scale(t), t.interpolation == "nearest"), + Some((texture, texel)) => texture.pick_display(painter.ctx(), zoom, texel, false, display), + None => pv.texture.pick_display(painter.ctx(), zoom, quad_scale(t), t.interpolation == "nearest", display), }; let mut mesh = egui::Mesh::with_texture(tex); let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8); @@ -2192,7 +2261,7 @@ pub fn draw_content(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); } if let Some(a) = &pv.above { - a.draw(painter, xf); + a.draw(painter, xf, display); } } @@ -2514,11 +2583,18 @@ fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; } /// The warped pixels and the layers above them ([`draw_content`] for a warp). -fn draw_warp_content(painter: &egui::Painter, xf: &ViewXform, t: &TransformSession, w: &Warp, pv: &TransformPreview) { +fn draw_warp_content( + painter: &egui::Painter, + xf: &ViewXform, + t: &TransformSession, + w: &Warp, + pv: &TransformPreview, + display: Option<&photocraft_engine::display_color::CanvasDisplay>, +) { let r = t.rect; let m = w.to_mesh(1, 1); let (us, vs) = crate::warp_preview::samples(&m, xf.zoom as f64); - let (tex, uv) = pv.texture.pick(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest"); + let (tex, uv) = pv.texture.pick_display(painter.ctx(), xf.zoom * painter.ctx().pixels_per_point(), 1.0, t.interpolation == "nearest", display); let mut mesh = egui::Mesh::with_texture(tex); let tint = Color32::from_white_alpha((pv.opacity.clamp(0.0, 1.0) * 255.0) as u8); for v in &vs { @@ -2532,7 +2608,7 @@ fn draw_warp_content(painter: &egui::Painter, xf: &ViewXform, t: &TransformSessi mesh.indices = crate::warp_preview::triangle_order(&pos, &rest, us.len()); painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); if let Some(a) = &pv.above { - a.draw(painter, xf); + a.draw(painter, xf, display); } } @@ -2849,6 +2925,41 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes } } +fn matching_anchor(quad: [[f64; 2]; 4], pivot: [f64; 2]) -> Option { + (0..9).find(|&i| { + let p = point_on_quad(quad, i); + (p[0] - pivot[0]).abs() <= 0.05 && (p[1] - pivot[1]).abs() <= 0.05 + }) +} + +fn anchor_grid_widget(ui: &mut egui::Ui, current_anchor: Option) -> Option { + let size = egui::vec2(16.0, 16.0); + let (rect, resp) = ui.allocate_exact_size(size, egui::Sense::click()); + let tk = crate::theme::Tokens::get(ui.ctx()); + let painter = ui.painter(); + painter.rect_stroke(rect, 1.0, egui::Stroke::new(1.0, tk.card_border), egui::StrokeKind::Inside); + let mut clicked = None; + let pointer = resp.interact_pointer_pos(); + for row in 0..3 { + for col in 0..3 { + let idx = row * 3 + col; + let cx = rect.min.x + (col as f32 + 0.5) * (rect.width() / 3.0); + let cy = rect.min.y + (row as f32 + 0.5) * (rect.height() / 3.0); + let pt_rect = egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(3.5, 3.5)); + if resp.clicked() && pointer.is_some_and(|p| (p.x - cx).abs() <= 3.0 && (p.y - cy).abs() <= 3.0) { + clicked = Some(idx); + } + if current_anchor == Some(idx) { + painter.rect_filled(pt_rect, 0.5, tk.accent); + } else { + painter.rect_filled(pt_rect, 0.5, tk.text_dim); + } + } + } + resp.on_hover_text(tl!("Reference point location")); + clicked +} + /// The reference point's X/Y and the W/H scale fields (with the aspect link) of the options bar. pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSession) { let tk = crate::theme::Tokens::get(ui.ctx()); @@ -2856,8 +2967,18 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let lbl = |ui: &mut egui::Ui, s: &str| { ui.label(egui::RichText::new(s).color(tk.text_dim).size(12.0)); }; - let mut px = t.pivot[0] as f32; - let mut py = t.pivot[1] as f32; + let current_anchor = matching_anchor(t.quad, t.pivot); + let mut pivot = t.pivot; + if let Some(new_anchor) = anchor_grid_widget(ui, current_anchor) { + let new_p = point_on_quad(t.quad, new_anchor); + if let Some(s) = app.ui.transform.as_mut() { + s.pivot = new_p; + } + pivot = new_p; + } + ui.add_space(4.0); + let mut px = pivot[0] as f32; + let mut py = pivot[1] as f32; lbl(ui, "X:"); let rx = crate::widgets::value_field(ui, &mut px, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0); lbl(ui, "Y:"); @@ -3663,6 +3784,134 @@ mod tests { assert!(app.transform_preview.is_none(), "the transform preview should end"); } + /// Polls the app's background jobs until they have all landed. + fn land_jobs(app: &mut PhotocraftApp) { + let ctx = egui::Context::default(); + for _ in 0..2000 { + crate::jobs_ui::tick(app, &ctx); + if !app.session.has_jobs() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("the background jobs never landed"); + } + + /// A smart object placed the way a dropped file is (the last step made it), in Free Transform + /// and moved 8 px right, with commits running in the background as in the desktop app: + /// (app, the placed layer, its frame before the move, history steps before the place). + fn moved_place() -> (PhotocraftApp, LayerId, photocraft_geom::Rect, usize) { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let steps = app.session.active().unwrap().history.past_len(); + app.session.execute("layer.duplicate", json!({"inPlace": true})).unwrap(); + app.sync_views(); + let st = app.session.active().unwrap(); + let placed = st.active_layer.unwrap(); + let frame = photocraft_engine::transform_cmds::transform_frame(&st.doc, st.doc.layer(placed).unwrap()); + begin_placed(&mut app, &egui::Context::default()).unwrap(); + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 8.0, y]); + app.background_jobs = true; + (app, placed, frame, steps) + } + + fn frame_of(app: &PhotocraftApp, layer: LayerId) -> Option { + let st = app.session.active()?; + Some(photocraft_engine::transform_cmds::transform_frame(&st.doc, st.doc.layer(layer)?)) + } + + /// #5665: picking another tool or selecting another layer while a dropped file is in Free + /// Transform applies the transform when the commit runs in the background too. Ending the + /// box used to cancel that commit, which took the dropped file back (or left a transformed + /// layer where it was). + #[test] + fn another_tool_or_layer_applies_a_transform_committed_in_the_background() { + for by_layer in [false, true] { + let (mut app, placed, frame, steps) = moved_place(); + if by_layer { + let below = app.session.active().unwrap().doc.layers.iter().map(|l| l.id).find(|id| *id != placed).unwrap(); + app.run("layer.select", json!({"layer": below.0})).unwrap(); + } else { + app.ui.tool = if app.ui.tool == Tool::Brush { Tool::Move } else { Tool::Brush }; + } + end_if_left(&mut app); + assert!(committing(&app).is_some(), "the commit runs in the background"); + land_jobs(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the box ended"); + assert_eq!(frame_of(&app, placed), Some(frame.translate(8, 0)), "the dropped file stays, moved (by layer: {by_layer})"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "the place and its move are one step"); + assert_eq!(st.history.undo_label(), Some(photocraft_engine::file_cmds::PLACE_EMBEDDED)); + } + } + + /// #5667: New Layer while a dropped file is in Free Transform applies the transform first. When + /// the commit runs in the background the command is refused until it has landed, then runs: the + /// place (which takes the move into its step) and the new layer each stay their own step, in + /// that order. + #[test] + fn new_layer_applies_the_open_transform() { + let (mut app, placed, frame, steps) = moved_place(); + let layers = app.session.active().unwrap().doc.layers.len(); + let err = app.run("layer.new.layer", json!({})).unwrap_err(); + assert!(err.contains("Free Transform is still being applied"), "refused while the commit runs: {err}"); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "no layer yet"); + land_jobs(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the box ended"); + assert_eq!(frame_of(&app, placed), Some(frame.translate(8, 0)), "applied as it showed, not the original"); + app.run("layer.new.layer", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers + 1); + assert_eq!(st.history.past_len(), steps + 2, "the place with its move, then New Layer"); + assert_eq!(st.history.undo_label(), Some("New Layer")); + app.session.undo(); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "one undo takes back the new layer"); + assert_eq!(frame_of(&app, placed), Some(frame.translate(8, 0)), "the transform stays"); + } + + /// #5665, the report's second case through the UI with background jobs on, as in the desktop + /// app: Free Transform on a layer, move it, then click another layer's row in the Layers panel. + #[test] + fn clicking_another_layer_row_applies_the_transform() { + use egui_kittest::kittest::{NodeT, Queryable}; + let app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let layer = app.session.active().unwrap().active_layer.unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let before = frame_of(h.state(), layer).unwrap(); + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + let t = h.state_mut().ui.transform.as_mut().expect("in Free Transform"); + t.quad = t.quad.map(|[x, y]| [x + 8.0, y]); + h.state_mut().background_jobs = true; + h.run_steps(2); + let row = h + .query_all_by_label("Background") + .find(|n| n.accesskit_node().role() == egui::accesskit::Role::Button) + .expect("the Background row") + .rect() + .center(); + h.hover_at(row); + h.run_steps(1); + h.drag_at(row); + h.run_steps(1); + h.drop_at(row); + for _ in 0..400 { + h.run_steps(1); + if h.state().ui.transform.is_none() && !h.state().session.has_jobs() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_ne!(h.state().session.active().unwrap().active_layer, Some(layer), "the click selected the Background"); + assert!(h.state().ui.transform.is_none(), "the box ended"); + assert_eq!(frame_of(h.state(), layer), Some(before.translate(8, 0)), "the move was applied"); + } + /// Large documents need scales past 10000% and positions past 30000 px (#1267). #[test] fn transform_fields_reach_large_documents() { @@ -4616,6 +4865,21 @@ mod tests { } } + /// The corners of a wide or tall box show the diagonal arrows, as in Photoshop, not the + /// horizontal or vertical one its long diagonal is nearest to. + #[test] + fn corner_cursors_stay_diagonal_on_a_long_box() { + use CursorIcon::{ResizeNeSw as NeSw, ResizeNwSe as NwSe}; + for rect in [photocraft_geom::Rect::new(8, 112, 248, 144), photocraft_geom::Rect::new(112, 8, 144, 248)] { + let mut app = app_with_square(256, rect); + crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap(); + let t = app.ui.transform.clone().unwrap(); + let xf = ViewXform::active(&app).unwrap(); + let got: Vec<_> = (0..4).map(|i| cursor(&app, &xf, t.quad[i], false)).collect(); + assert_eq!(got, [NwSe, NeSw, NwSe, NeSw].map(Some), "{rect:?}"); + } + } + /// A warp split's arrow crosses the split line as it looks on screen: it turns with a turned /// grid (Warp entered on a rotated box) and with a rotated view. #[test] @@ -4954,6 +5218,80 @@ mod tests { ); } + fn artboard_preview_app(background: photocraft_doc::ArtboardBackground) -> (PhotocraftApp, LayerId, LayerId) { + use photocraft_doc::{Artboard, Layer}; + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let red = doc.layers.pop().unwrap(); + let red_id = red.id; + let mut blue = Layer::raster("covering blue", photocraft_color::PixelFormat::RGBA8); + blue.surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(16, 16, 40, 40), &[0.0, 0.0, 1.0, 1.0]); + let mut board = Layer::group("board", vec![red, blue]); + let board_id = board.id; + if let LayerContent::Group(g) = &mut board.content { + let mut ab = Artboard::new(photocraft_geom::Rect::new(0, 0, 64, 64)); + ab.background = background; + g.artboard = Some(ab); + } + doc.layers = vec![board]; + (app, board_id, red_id) + } + + #[test] + fn artboard_transform_overlay_does_not_cover_moving_pixels_with_the_board_background() { + use photocraft_doc::ArtboardBackground; + for background in [ArtboardBackground::White, ArtboardBackground::Black] { + let (mut app, board_id, red_id) = artboard_preview_app(background); + let original = app.session.active().unwrap().doc.clone(); + begin(&mut app, &gpu_ctx()).unwrap(); + let pv = app.transform_preview.as_ref().unwrap(); + let (texture, rect) = pv.above.as_ref().unwrap().shown.as_ref().unwrap(); + let (image, _) = texture.level_image(0).unwrap(); + let at = |x: i32, y: i32| image.pixels[(y - rect.y0) as usize * image.size[0] + (x - rect.x0) as usize]; + assert_eq!(at(12, 12), Color32::TRANSPARENT, "the board background belongs under the moving layer"); + assert_eq!(at(20, 20), Color32::BLUE, "covering siblings must still cover the moving layer"); + let under = photocraft_compose::render(&pv.doc, photocraft_geom::Rect::new(0, 0, 64, 64)); + assert_eq!(under.px[12 * 64 + 12], background.rgba().unwrap(), "the board background remains on the base canvas"); + assert!(!pv.doc.layer(red_id).unwrap().visible); + cancel(&mut app); + let LayerContent::Group(g) = &app.session.active().unwrap().doc.layer(board_id).unwrap().content else { panic!("board") }; + assert_eq!(g.artboard.as_ref().unwrap().background, background); + assert!(Arc::ptr_eq(&app.session.active().unwrap().doc, &original), "preview and cancellation do not edit the document"); + } + } + + #[test] + fn artboard_solo_preview_keeps_holes_transparent_and_the_board_clip() { + use photocraft_doc::ArtboardBackground; + let (app, board_id, red_id) = artboard_preview_app(ArtboardBackground::White); + let original = &app.session.active().unwrap().doc; + let mut solo = (**original).clone(); + solo.layer_mut(red_id).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(-2, -2, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + solo_layer(&mut solo.layers, red_id); + let b = photocraft_geom::Rect::new(-2, -2, 66, 66); + let rendered = photocraft_compose::render(&solo, b); + let at = |x: i32, y: i32| rendered.px[(y + 2) as usize * 68 + (x + 2) as usize]; + assert_eq!(at(12, 12), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(at(30, 30), [0.0; 4], "neither the board's white nor another child enters the moving texture"); + assert_eq!(at(-1, -1), [0.0; 4], "the artboard still clips its children"); + let LayerContent::Group(g) = &solo.layer(board_id).unwrap().content else { panic!("board") }; + assert!(g.artboard.is_some(), "keep the board boundary while omitting its background"); + } + + #[test] + fn artboard_moved_as_a_whole_keeps_its_background() { + use photocraft_doc::ArtboardBackground; + let (app, board_id, _) = artboard_preview_app(ArtboardBackground::Black); + let mut doc = (*app.session.active().unwrap().doc).clone(); + solo_layer(&mut doc.layers, board_id); + let rendered = photocraft_compose::render(&doc, photocraft_geom::Rect::new(0, 0, 64, 64)); + assert_eq!(rendered.px[50 * 64 + 50], [0.0, 0.0, 0.0, 1.0]); + show_only(&mut doc.layers, &[board_id]); + let rendered = photocraft_compose::render(&doc, photocraft_geom::Rect::new(0, 0, 64, 64)); + assert_eq!(rendered.px[50 * 64 + 50], [0.0, 0.0, 0.0, 1.0], "a whole covering artboard keeps its own background"); + } + #[test] fn nested_group_preview_detaches_every_clipped_ancestor() { use photocraft_color::PixelFormat; @@ -5245,6 +5583,67 @@ mod tests { assert!(after.points[5] != before.points[5], "the inside of the grid moved"); } + /// #7885: a surface drag (a press inside a cell, on no anchor or handle) commits exactly as + /// the preview draws it. The layer is a gradient whose colour encodes where each pixel came + /// from, so every committed pixel says which source point landed there; the preview puts + /// source point `p` at `warp.map(p)`, and the commit must agree. + #[test] + fn warp_surface_drag_commits_as_previewed() { + const N: i32 = 96; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": N, "height": N})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let code = |x: f64, y: f64| [x / (N - 1) as f64, y / (N - 1) as f64]; + app.session + .edit("paint", |doc, a| { + let s = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + for y in 0..N { + for x in 0..N { + let [r, g] = code(x as f64, y as f64); + s.write_pixel(x, y, &[r as f32, g as f32, 0.5, 1.0]); + } + } + Ok(()) + }) + .unwrap(); + begin_warp(&mut app, &egui::Context::default()).unwrap(); + let (start, end) = ([30.0, 30.0], [39.0, 25.0]); + // A press well inside the top-left cell: a surface drag, not a point drag. + warp_pointer(&mut app, ToolEvent::Down { x: start[0], y: start[1], pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + assert!(matches!(app.transform_preview.as_ref().unwrap().warp_drag, Some(WarpDrag::Surface { .. }))); + warp_pointer(&mut app, ToolEvent::Move { x: end[0], y: end[1], pressure: 1.0 }, 2.0, egui::Modifiers::NONE); + warp_pointer(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, 2.0, egui::Modifiers::NONE); + // What the preview draws: the mapping of the warp the session holds. + let previewed = app.ui.transform.as_ref().unwrap().warp.clone().unwrap(); + let landed = previewed.map(start[0], start[1]); + assert!((landed.0 - end[0]).abs() < 1e-6 && (landed.1 - end[1]).abs() < 1e-6, "the grabbed point follows the pointer: {landed:?}"); + commit(&mut app); + assert!(app.ui.transform.is_none(), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let s = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let from = |x: f64, y: f64| { + let mut p = [0.0f32; 4]; + s.read_pixel(x.round() as i32, y.round() as i32, &mut p); + [p[0] as f64 * (N - 1) as f64, p[1] as f64 * (N - 1) as f64] + }; + // The grabbed point's pixel moved with the pointer: the drag did not get lost. + let got = from(end[0], end[1]); + assert!((got[0] - start[0]).abs() < 2.0 && (got[1] - start[1]).abs() < 2.0, "pixel at the drop point came from {got:?}, not {start:?}"); + // And everywhere else the committed pixels sit where the preview put them. + for j in 1..12 { + for i in 1..12 { + let src = [i as f64 * 8.0, j as f64 * 8.0]; + let (dx, dy) = previewed.map(src[0], src[1]); + if !(1.0..(N - 2) as f64).contains(&dx) || !(1.0..(N - 2) as f64).contains(&dy) { + continue; + } + let got = from(dx, dy); + assert!((got[0] - src[0]).abs() < 2.0 && (got[1] - src[1]).abs() < 2.0, "{src:?} previewed at {:?} but committed {got:?} there", (dx, dy)); + } + } + } + #[test] fn a_handle_belongs_to_the_anchor_it_hangs_off() { assert_eq!(handle_anchor(0, 1), (0, 0)); @@ -5366,6 +5765,59 @@ mod tests { img.pixels[y * img.size[0] + x].a() } + #[test] + fn transform_preview_preserves_displayed_profile_colors() { + let bounds = photocraft_geom::Rect::new(0, 0, 8, 8); + for (wide_gamut, monitor, warp) in [(false, "srgb", false), (true, "srgb", false), (false, "display-p3", false), (true, "srgb", true)] { + let mut app = app_with_square(8, bounds); + app.session + .edit("original color sample", |doc, active| { + if wide_gamut { + doc.icc_profile = Some(photocraft_cms::Builtin::AdobeRgbCompat.profile().to_bytes()); + } + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.55, 0.35, 0.2, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.color.settings.monitor_profile = monitor.into(); + let st = app.session.active().unwrap(); + let display = app.session.color.canvas_display(&st.doc).unwrap(); + assert_eq!(!display.is_identity(), wide_gamut || monitor != "srgb"); + let composite = photocraft_compose::render(&st.doc, bounds); + let expected = crate::canvas::display_image(Some(&display), &composite); + let ctx = gpu_ctx(); + if warp { + begin_warp(&mut app, &ctx).unwrap(); + } else { + begin(&mut app, &ctx).unwrap(); + } + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(80.0, 80.0)), + zoom: 1.0, + center: [4.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(egui::RawInput { max_texture_side: Some(16384), ..Default::default() }, |ui| { + draw_content(&app, &ui.ctx().layer_painter(egui::LayerId::background()), &xf); + }); + let texture = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id != egui::TextureId::default() => Some(mesh.texture_id), + _ => None, + }) + .expect("transform draws a textured mesh"); + let upload = out.textures_delta.set.get(&texture).and_then(|updates| updates.last()).expect("drawn texture was uploaded"); + let egui::ImageData::Color(image) = &upload.image; + let shown = image.pixels[0]; + out.textures_delta.clear(); + assert_eq!(shown, expected.pixels[0], "transform changed displayed color; wide_gamut={wide_gamut}, monitor={monitor}, warp={warp}"); + } + } + #[test] fn preview_applies_the_linked_mask() { for linked in [true, false] { @@ -5393,6 +5845,150 @@ mod tests { assert_eq!(texel_alpha(&img, 10, 4), 255, "revealed"); } + #[test] + fn transform_selection_tint_is_not_document_color() { + let mut app = app_with_square(8, photocraft_geom::Rect::new(0, 0, 8, 8)); + app.session.color.settings.monitor_profile = "display-p3".into(); + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let ctx = gpu_ctx(); + begin_selection(&mut app, &ctx).unwrap(); + let raw = app.transform_preview.as_ref().unwrap().texture.level_image(0).unwrap().0; + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(80.0, 80.0)), + zoom: 1.0, + center: [4.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_content(&app, &ui.ctx().layer_painter(egui::LayerId::background()), &xf); + }); + let id = out + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => Some(m.texture_id), + _ => None, + }) + .unwrap(); + let egui::ImageData::Color(shown) = &out.textures_delta.set.get(&id).unwrap().last().unwrap().image; + assert_eq!(shown.pixels, raw.pixels); + out.textures_delta.clear(); + } + + #[test] + fn transform_covering_layer_uses_the_same_monitor_profile() { + let bounds = photocraft_geom::Rect::new(0, 0, 8, 8); + let mut app = app_with_square(8, bounds); + let lower = app.session.active().unwrap().active_layer.unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + let upper = app.session.active().unwrap().active_layer.unwrap(); + app.session + .edit("original color samples", |doc, _| { + doc.layer_mut(lower).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.55, 0.35, 0.2, 1.0]); + doc.layer_mut(upper).unwrap().surface_mut().unwrap().fill_rect(bounds, &[0.2, 0.5, 0.7, 1.0]); + Ok(()) + }) + .unwrap(); + app.session.color.settings.monitor_profile = "display-p3".into(); + let doc = app.session.active().unwrap().doc.clone(); + let display = app.session.color.canvas_display(&doc).unwrap(); + let expected = [lower, upper].map(|id| { + let mut solo = (*doc).clone(); + for layer in &mut solo.layers { + layer.visible = layer.id == id; + } + crate::canvas::display_image(Some(&display), &photocraft_compose::render(&solo, bounds)).pixels[0] + }); + app.session.execute("layer.select", json!({"layer": lower.0})).unwrap(); + let ctx = gpu_ctx(); + begin(&mut app, &ctx).unwrap(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(80.0, 80.0)), + zoom: 1.0, + center: [4.0, 4.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + draw_content(&app, &ui.ctx().layer_painter(egui::LayerId::background()), &xf); + }); + let shown: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => { + let egui::ImageData::Color(image) = &out.textures_delta.set.get(&m.texture_id).unwrap().last().unwrap().image; + Some(image.pixels[0]) + } + _ => None, + }) + .collect(); + out.textures_delta.clear(); + assert_eq!(shown, expected, "moving pixels and covering pixels use the same display conversion"); + } + + #[test] + #[ignore = "requires a GPU adapter; renders profile-managed transform evidence"] + fn transform_profile_visual_evidence() { + let _guard = photocraft_testkit::gpu_lock(); + let destination = std::env::var("PHOTOCRAFT_TRANSFORM_EVIDENCE").unwrap_or_else(|_| "transform-profile-evidence".into()); + std::fs::create_dir_all(&destination).unwrap(); + for monitor in ["srgb", "display-p3"] { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.set_wgpu(cc.wgpu_render_state.as_ref().unwrap().clone()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "transparent"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.session + .edit("original color swatches", |doc, active| { + let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for (x, color) in [(60, [0.55, 0.35, 0.2, 1.0]), (160, [0.2, 0.6, 0.35, 1.0]), (260, [0.3, 0.4, 0.7, 1.0])] { + surface.fill_rect(photocraft_geom::Rect::new(x, 60, x + 80, 220), &color); + } + Ok(()) + }) + .unwrap(); + app.session.color.settings.monitor_profile = monitor.into(); + app.ui.extras.rulers = false; + app.sync_views(); + app.ui.views[0].zoom = 1.0; + app.ui.views[0].center = [200.0, 150.0]; + app.ui.views[0].fit_pending = false; + app + }); + h.run_steps(8); + let app = h.state(); + let view = &app.ui.views[0]; + let xf = ViewXform { rect: app.last_canvas_rect, zoom: view.zoom, center: view.center, flip: false, rotation: 0.0, aspect: 1.0 }; + let points = [100.0, 200.0, 300.0].map(|x| xf.to_screen(x, 100.0) * h.ctx.pixels_per_point()); + let before = h.render().unwrap(); + before.save(std::path::Path::new(&destination).join(format!("{monitor}-before.png"))).unwrap(); + let ctx = h.ctx.clone(); + begin(h.state_mut(), &ctx).unwrap(); + h.run_steps(8); + assert!(h.state().ui.transform.is_some()); + let during = h.render().unwrap(); + during.save(std::path::Path::new(&destination).join(format!("{monitor}-during.png"))).unwrap(); + commit(h.state_mut()); + h.run_steps(8); + assert!(h.state().ui.transform.is_none()); + let after = h.render().unwrap(); + after.save(std::path::Path::new(&destination).join(format!("{monitor}-after.png"))).unwrap(); + for point in points { + let (x, y) = (point.x as u32, point.y as u32); + let expected = before.get_pixel(x, y).0; + assert_ne!(expected[0], expected[1], "sample must hit a color swatch, not the checkerboard"); + for actual in [during.get_pixel(x, y).0, after.get_pixel(x, y).0] { + assert!(actual.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 2), "{monitor}: before={expected:?}, shown={actual:?}"); + } + } + } + } + #[test] fn preview_feathers_the_linked_mask() { // A feathered pixel mask without a vector mask: the compositor softens the edge at x 16, @@ -5578,4 +6174,30 @@ mod tests { assert!(matches!(&st.doc.layer(st.active_layer.unwrap()).unwrap().content, LayerContent::Text(t) if t.warp.is_none())); assert_eq!(past(&app), before + 2); } + + #[test] + fn anchor_grid_point_on_quad_and_carry_pivot() { + let quad = [[0.0, 0.0], [100.0, 0.0], [100.0, 50.0], [0.0, 50.0]]; + assert_eq!(point_on_quad(quad, 0), [0.0, 0.0]); + assert_eq!(point_on_quad(quad, 1), [50.0, 0.0]); + assert_eq!(point_on_quad(quad, 2), [100.0, 0.0]); + assert_eq!(point_on_quad(quad, 3), [0.0, 25.0]); + assert_eq!(point_on_quad(quad, 4), [50.0, 25.0]); + assert_eq!(point_on_quad(quad, 5), [100.0, 25.0]); + assert_eq!(point_on_quad(quad, 6), [0.0, 50.0]); + assert_eq!(point_on_quad(quad, 7), [50.0, 50.0]); + assert_eq!(point_on_quad(quad, 8), [100.0, 50.0]); + + let quad_scaled = [[0.0, 0.0], [200.0, 0.0], [200.0, 100.0], [0.0, 100.0]]; + let p_top_right = carry_pivot(quad, quad_scaled, [100.0, 0.0]); + assert_eq!(p_top_right, [200.0, 0.0]); + let p_center = carry_pivot(quad, quad_scaled, [50.0, 25.0]); + assert_eq!(p_center, [100.0, 50.0]); + + for anchor in 0..9 { + assert_eq!(matching_anchor(quad, point_on_quad(quad, anchor)), Some(anchor)); + } + assert_eq!(matching_anchor(quad, [12.3, 45.6]), None); + assert_eq!(matching_anchor(quad, [50.0, 30.0]), None); + } } diff --git a/crates/ui-egui/src/type_commit_tests.rs b/crates/ui-egui/src/type_commit_tests.rs new file mode 100644 index 0000000000..e25fc33977 --- /dev/null +++ b/crates/ui-egui/src/type_commit_tests.rs @@ -0,0 +1,185 @@ +//! A type edit ends when its layer stops being the one worked on (#6483): another layer or +//! another document became active. Photoshop commits the text then; left open, the session kept +//! taking single-key shortcuts as typing and could type into a layer of the wrong document. + +use egui_kittest::Harness; +use photocraft_doc::{LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn new_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Type; + app +} + +fn text_of(app: &PhotocraftApp, doc: usize, id: LayerId) -> Option { + match &app.session.documents().get(doc)?.doc.layer(id)?.content { + LayerContent::Text(t) => Some(t.text.clone()), + _ => None, + } +} + +/// Start a new type layer with a click and type `s` into it, as the canvas does. +fn type_new(app: &mut PhotocraftApp, s: &str) -> LayerId { + super::pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + super::insert(app, s); + LayerId(app.ui.text_edit.as_ref().unwrap().layer) +} + +/// Feed one frame of key handling with `events`. +fn keys(app: PhotocraftApp, events: Vec) -> PhotocraftApp { + let mut h = Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + super::handle_keys(app, ui.ctx()); + }, + app, + ); + h.run_steps(1); + for e in events { + h.event(e); + } + h.run_steps(1); + h.into_state() +} + +#[test] +fn typing_after_a_document_switch_never_edits_the_other_document() { + let mut app = new_app(); + let id = app.run("type.create", json!({"text": "Bee", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + // A second document with the same layer ids, as when one .pcraft is opened twice (its saved + // ids are kept; only the document id is made unique). + let copy = (*app.session.active().unwrap().doc).clone(); + app.session.add_document(copy, None); + app.sync_views(); + // Edit the copy's type layer (index 1), then switch to the first document's tab and type. + crate::type_tool::edit_active(&mut app).unwrap(); + super::insert(&mut app, "Ay"); + assert!(app.session.set_active(0)); + super::end_if_left(&mut app); + let app = keys(app, vec![egui::Event::Text("Z".into())]); + assert_eq!(text_of(&app, 0, id).as_deref(), Some("Bee"), "the other document is untouched"); + assert_eq!(text_of(&app, 1, id).as_deref(), Some("Ay"), "the edited copy keeps its text"); + assert!(app.ui.text_edit.is_none(), "the copy's edit was committed"); + assert_eq!(app.session.active_index(), Some(0), "and the document switched to stays active"); +} + +#[test] +fn selecting_another_layer_commits_the_edit() { + let mut app = new_app(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + let text = type_new(&mut app, "Hello"); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_some(), "a session's own layer is active: it goes on"); + // The Layers panel menus, the canvas layer picker, ⌥[ / ⌥] and automation all select this way. + app.run("layer.select", json!({"layer": other.0})).unwrap(); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.active_layer, Some(other), "the selection stands"); + assert_eq!(st.doc.layer(text).unwrap().name, "Hello", "committed: the new layer is named after its text"); + assert_eq!(text_of(&app, 0, text).as_deref(), Some("Hello")); +} + +#[test] +fn an_empty_new_layer_goes_when_another_layer_is_selected() { + let mut app = new_app(); + let other = LayerId(app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap()); + let text = type_new(&mut app, ""); + app.run("layer.select", json!({"layer": other.0})).unwrap(); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + assert!(app.session.active().unwrap().doc.layer(text).is_none(), "an empty new type layer is deleted on commit"); +} + +#[test] +fn deselecting_all_layers_commits_the_edit() { + let mut app = new_app(); + type_new(&mut app, "Hello"); + app.run("select.deselectLayers", json!({})).unwrap(); + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); +} + +#[test] +fn a_type_mask_session_goes_on_while_its_layer_is_active() { + let mut app = new_app(); + app.ui.tool = crate::state::Tool::HorizontalTypeMask; + type_new(&mut app, "Mask"); + super::end_if_left(&mut app); + let ed = app.ui.text_edit.as_ref().expect("the Type Mask session is still open"); + assert!(ed.mask); +} + +#[test] +fn closing_the_edited_document_drops_the_edit_without_touching_another() { + let mut app = new_app(); + let id = app.run("type.create", json!({"text": "Bee", "size": 24, "x": 20, "y": 80})).unwrap()["layer"].as_u64().map(LayerId).unwrap(); + let copy = (*app.session.active().unwrap().doc).clone(); + app.session.add_document(copy, None); + app.sync_views(); + crate::type_tool::edit_active(&mut app).unwrap(); + super::insert(&mut app, ""); + assert!(app.session.close(1).is_some()); + let before = app.session.active().unwrap().revision; + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + assert_eq!(app.session.active().unwrap().revision, before, "nothing ran in the remaining document"); + assert_eq!(text_of(&app, 0, id).as_deref(), Some("Bee"), "its same-id type layer keeps its text"); +} + +/// The issue's second symptom: after a layer is selected, single-key tool shortcuts work again +/// instead of typing into the type layer. +#[test] +fn tool_shortcuts_work_after_another_layer_is_selected() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let app = h.state_mut(); + app.ui.tool = crate::state::Tool::Type; + let other = app.run("layer.new.layer", json!({"name": "Other pixels"})).unwrap()["layer"].as_u64().unwrap(); + let text = type_new(app, "Hello"); + app.run("layer.select", json!({"layer": other})).unwrap(); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none(), "the frame commits the edit"); + h.key_press(egui::Key::V); + h.event(egui::Event::Text("v".into())); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move, "V picks the Move tool"); + assert_eq!(text_of(h.state(), 0, text).as_deref(), Some("Hello"), "and types nothing"); +} + +/// Ending an empty Type Mask undoes its session, and undo restores the layer selection from +/// before it: the layer just selected (or Deselect Layers) must stand. So must it when a Type +/// Mask with text becomes the selection and its working layer goes. +#[test] +fn ending_a_type_mask_keeps_the_new_layer_selection() { + for (deselect, text) in [(false, ""), (true, ""), (false, "Mask"), (true, "Mask")] { + let mut app = new_app(); + let first = LayerId(app.run("layer.new.layer", json!({"name": "First"})).unwrap()["layer"].as_u64().unwrap()); + let second = LayerId(app.run("layer.new.layer", json!({"name": "Second"})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": first.0})).unwrap(); + app.ui.tool = crate::state::Tool::HorizontalTypeMask; + let mask = type_new(&mut app, text); + if deselect { + app.run("select.deselectLayers", json!({})).unwrap(); + } else { + app.run("layer.select", json!({"layer": second.0})).unwrap(); + } + super::end_if_left(&mut app); + assert!(app.ui.text_edit.is_none()); + let st = app.session.active().unwrap(); + assert!(st.doc.layer(mask).is_none(), "{text:?}: the mask's working layer is gone"); + assert_eq!(st.doc.selection.is_some(), !text.is_empty(), "{text:?}: text becomes the selection"); + let want = if deselect { None } else { Some(second) }; + assert_eq!(st.active_layer, want, "{text:?}, deselect={deselect}: the new selection stands"); + assert_eq!(st.selected_layers(), want.into_iter().collect::>(), "{text:?}, deselect={deselect}"); + } +} diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 6f05ed61a5..12c1dec020 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -13,6 +13,7 @@ struct State { button: egui::Id, popup: egui::Id, preview: FontPreview, + recents: Vec, } fn harness() -> Harness<'static, State> { @@ -20,16 +21,21 @@ fn harness() -> Harness<'static, State> { } fn harness_with(preview: FontPreview) -> Harness<'static, State> { + harness_with_recents(preview, Vec::new()) +} + +fn harness_with_recents(preview: FontPreview, recents: Vec) -> Harness<'static, State> { // Enough families that the unfiltered menu hits its maximum height and scrolls. let mut families: Vec = (0..60).map(|i| format!("Family {i:02}")).collect(); families.push("Zeta Sans".into()); - let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview }; + let state = State { current: "Family 00".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview, recents }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); s.popup = s.button.with("popup"); let fams = s.families.clone(); - font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let recents = s.recents.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents); }, state, ); @@ -112,6 +118,54 @@ fn hovering_a_font_row_moves_no_other_row() { } } +/// #7792: the search field stays at the top of the menu while the list scrolls under it. The +/// menu's own scroll area used to carry the field away with the rows. +#[test] +fn the_search_field_stays_put_while_the_list_scrolls() { + let mut h = harness(); + open(&mut h); + let search = h.get_by_role(Role::TextInput).rect(); + let popup = popup_rect(&h).expect("font menu open"); + let first_row = h.get_by_label("Family 00").rect(); + + // The wheel over a row scrolls the list. + h.hover_at(h.get_by_label("Family 05").rect().center()); + for _ in 0..6 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -3.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + h.run(); + } + let scrolled = h.get_by_label("Family 00").rect(); + assert!(scrolled.top() < first_row.top() - 1.0, "the list did not scroll: {first_row:?} -> {scrolled:?}"); + + // The field did not move with the rows: it is still where it was, inside the menu. + let after = h.get_by_role(Role::TextInput).rect(); + assert_eq!(search, after, "the search field scrolled away with the list"); + assert!(popup.contains(after.center()), "the search field left the menu"); +} + +/// #7792: the arrow keys scroll the list to the selected family without moving the search +/// field: they scroll the list, not the whole menu. +#[test] +fn the_search_field_stays_put_while_the_arrows_scroll_the_list() { + let mut h = harness(); + open(&mut h); + let search = h.get_by_role(Role::TextInput).rect(); + for _ in 0..40 { + h.key_press(Key::ArrowDown); + h.run(); + } + assert_eq!(h.state().current, "Family 40"); + let row = h.get_by_label("Family 40").rect(); + let popup = popup_rect(&h).expect("font menu open"); + assert!(popup.contains(row.center()), "the selected row is in view: {row:?} in {popup:?}"); + assert_eq!(search, h.get_by_role(Role::TextInput).rect(), "the search field scrolled away with the list"); +} + #[test] fn clearing_the_query_restores_the_menu_height() { let mut h = harness(); @@ -284,14 +338,15 @@ fn arrows_on_the_field_step_through_the_families_after_a_pick() { fn the_first_arrow_after_a_pick_keeps_the_focus_between_neighbours() { for (enter, picked, stepped) in [(false, "Family 05", "Family 06"), (true, "Family 04", "Family 05")] { let families: Vec = (0..20).map(|i| format!("Family {i:02}")).collect(); - let state = State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off }; + let state = + State { current: "Family 03".into(), families, button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Off, recents: Vec::new() }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { let _ = ui.button("Above"); s.button = ui.make_persistent_id(egui::IdSalt::new("type-font")); s.popup = s.button.with("popup"); let fams = s.families.clone(); - font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &s.recents); let _ = ui.button("Below"); }, state, @@ -545,6 +600,7 @@ fn hovering_a_font_previews_only_the_selected_characters() { app.run("type.setStyle", json!({"layer": layer.0, "range": [0, 2], "font": "JetBrains Mono"})).unwrap(); app.ui.text_edit = Some(crate::state::TextEdit { layer: layer.0, + document: None, caret: 5, anchor: 3, session: "font-preview-test".into(), @@ -598,12 +654,14 @@ fn localized_font_names_label_the_menu_but_pick_the_family() { button: egui::Id::NULL, popup: egui::Id::NULL, preview: FontPreview::Medium, + recents: Vec::new(), }; let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state( |ui, s: &mut State| { s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup"); let fams = s.families.clone(); - font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview); + let recents = s.recents.clone(); + font_picker_in(ui, &mut s.current, 170.0, &fams, s.preview, &recents); }, state, ); @@ -638,7 +696,7 @@ fn font_menu_rows_search_both_names_and_sort_by_label() { let labels: HashMap = [("MS Gothic", "MS ゴシック"), ("Microsoft YaHei", "微软雅黑")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); let rows = |labels: Option<&HashMap>, q: &str| -> Vec<(String, String)> { - super::font_menu_rows(&families, labels, q).into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect() + super::font_menu_rows(&families, labels, q, &[]).0.into_iter().map(|(f, l)| (f.clone(), l.to_string())).collect() }; let english = rows(None, ""); assert!(english.iter().all(|(f, l)| f == l)); @@ -649,6 +707,123 @@ fn font_menu_rows_search_both_names_and_sort_by_label() { assert!(rows(Some(&labels), "nothing").is_empty()); } +/// Preferences ▸ Type ▸ Recent Fonts: as in Photoshop, recently used families head the menu in +/// their own group (most recent first, once each) and keep their alphabetical place below it; a +/// query lists each match once, alphabetically. +#[test] +fn recent_fonts_head_the_menu_and_keep_their_place() { + let families: Vec = ["Arial", "Baskerville", "Courier", "Zeta Sans"].iter().map(|s| s.to_string()).collect(); + let recents: Vec = vec!["Zeta Sans".into(), "Courier".into(), "Gone".into()]; + let rows = |q: &str| -> (Vec, usize) { + let (rows, n) = super::font_menu_rows(&families, None, q, &recents); + (rows.into_iter().map(|(f, _)| f.clone()).collect(), n) + }; + assert_eq!( + rows(""), + (vec!["Zeta Sans".into(), "Courier".into(), "Arial".into(), "Baskerville".into(), "Courier".into(), "Zeta Sans".into()], 2), + "a recent group (no uninstalled family), then the whole alphabetical list" + ); + assert_eq!(rows("a"), (vec!["Arial".into(), "Baskerville".into(), "Zeta Sans".into()], 0), "a query lists each match once"); +} + +/// The menu itself draws the recent group first, and the recent families again in their place. +#[test] +fn recent_fonts_appear_first_in_the_menu() { + let mut h = harness_with_recents(FontPreview::Medium, vec!["Zeta Sans".into(), "Family 05".into()]); + open(&mut h); + let tops = |h: &Harness<'static, State>, label: &str| -> Vec { + let mut t: Vec = h.query_all_by_label(label).map(|n| n.rect().top()).collect(); + t.sort_by(f32::total_cmp); + t + }; + let (zeta, five, first, six) = (tops(&h, "Zeta Sans"), tops(&h, "Family 05"), tops(&h, "Family 00"), tops(&h, "Family 06")); + assert_eq!(five.len(), 2, "a recent family is listed twice: {five:?}"); + assert!(zeta[0] < five[0] && five[0] < first[0], "the recent group comes first, in order: {zeta:?} {five:?} {first:?}"); + assert!(first[0] < five[1] && five[1] < six[0], "and keeps its alphabetical place: {five:?} {six:?}"); + assert!(first[0] - five[0] > five[0] - zeta[0], "a divider ends the recent group: {zeta:?} {five:?} {first:?}"); +} + +/// The open menu's arrows walk on through the list although each step records its family as +/// the most recent one (CodeRabbit on #3559): the recent group keeps its order while the menu +/// is open, and a step from a recent family's lower copy goes on from there. +#[test] +fn open_menu_arrows_walk_on_through_recent_fonts() { + let mut h = harness_with_recents(FontPreview::Off, vec!["Family 03".into()]); + open(&mut h); + let mut seen = vec![h.state().current.clone()]; + for _ in 0..5 { + h.key_press(Key::ArrowDown); + h.run(); + let now = h.state().current.clone(); + // The options bar records each applied family as the most recent one. + h.state_mut().recents.retain(|f| *f != now); + h.state_mut().recents.insert(0, now.clone()); + h.run(); + seen.push(now); + } + assert_eq!(seen, ["Family 00", "Family 01", "Family 02", "Family 03", "Family 04", "Family 05"], "{seen:?}"); + h.key_press(Key::ArrowUp); + h.run(); + assert_eq!(h.state().current, "Family 04", "↑ steps back"); +} + +/// Opened on a family that is also a recent one (the usual case: every pick records it), the +/// arrows start from its alphabetical place, and only that row is marked. +#[test] +fn open_menu_on_a_recent_font_steps_through_the_alphabet() { + use egui_kittest::kittest::NodeT; + for (key, want) in [(Key::ArrowDown, "Family 06"), (Key::ArrowUp, "Family 04")] { + let mut h = harness_with_recents(FontPreview::Off, vec!["Family 05".into(), "Family 09".into()]); + h.state_mut().current = "Family 05".into(); + h.run(); + open(&mut h); + let marked = |h: &Harness<'static, State>, label: &str| { + h.query_all_by_label(label).filter(|n| n.accesskit_node().toggled() == Some(egui::accesskit::Toggled::True)).count() + }; + assert_eq!(marked(&h, "Family 05"), 1, "one row marked"); + h.key_press(key); + h.run(); + assert_eq!(h.state().current, want); + h.key_press(Key::ArrowDown); + h.key_press(Key::ArrowUp); + h.run(); + assert_eq!(h.state().current, want, "and walks on from there"); + } +} + +/// The same through the real options bar, where the pick itself records the recent family. +#[test] +fn options_bar_menu_arrows_walk_on_while_recording_recent_fonts() { + use crate::{PhotocraftApp, Services}; + use serde_json::json; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| crate::type_tool::options_bar(app, ui)); + } + }, + app, + ); + crate::PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.query_all_by_role(Role::ComboBox).next().unwrap().click(); + h.run(); + let mut seen = vec![h.state().ui.tool_options.type_font.clone()]; + for _ in 0..4 { + h.key_press(Key::ArrowDown); + h.run(); + seen.push(h.state().ui.tool_options.type_font.clone()); + } + let mut once = seen.clone(); + once.sort(); + once.dedup(); + assert_eq!(once.len(), seen.len(), "each ↓ reaches a new family: {seen:?}"); + assert_eq!(h.state().ui.recent_fonts.first(), seen.last(), "the picks are recorded"); +} + #[test] fn wheel_over_the_closed_font_button_steps_through_the_families() { let mut h = harness(); @@ -668,6 +843,30 @@ fn wheel_over_the_closed_font_button_steps_through_the_families() { assert_eq!(h.state().current, "Family 00", "stops at the first family"); } +#[test] +fn wheel_over_the_closed_button_keeps_a_stable_order_with_recents() { + let mut h = harness_with_recents(FontPreview::Medium, vec![]); + let button = h.query_all_by_role(Role::ComboBox).next().unwrap().rect().center(); + h.hover_at(button); + h.run(); + let wheel = |h: &mut Harness<'static, State>, y: f32| { + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }); + h.run(); + }; + wheel(&mut h, -1.0); + assert_eq!(h.state().current, "Family 01"); + // The caller (the options bar) records the picked family as the most recent one. + h.state_mut().recents.insert(0, "Family 01".into()); + h.run(); + wheel(&mut h, -1.0); + assert_eq!(h.state().current, "Family 02", "the next notch keeps going, not back to the recent family"); + h.state_mut().recents.insert(0, "Family 02".into()); + h.run(); + wheel(&mut h, -1.0); + assert_eq!(h.state().current, "Family 03"); + assert!(popup_rect(&h).is_none(), "the list stays closed"); +} + #[test] fn wheel_family_index_clamps_to_the_ends() { assert_eq!(super::wheel_family_index(0, 3, -5.0), Some(2)); diff --git a/crates/ui-egui/src/type_font_preview.rs b/crates/ui-egui/src/type_font_preview.rs index 4c652ed7ce..7f420d2494 100644 --- a/crates/ui-egui/src/type_font_preview.rs +++ b/crates/ui-egui/src/type_font_preview.rs @@ -10,6 +10,11 @@ use crate::PhotocraftApp; const TAG: u64 = 1 << 60; +/// How long the pointer has to rest on a served family that isn't fetched yet before hovering it +/// fetches it (seconds). Hovering asks the host for the whole family, and sweeping the font menu +/// would otherwise download every family the pointer passes; installed fonts preview at once. +const HOVER_FETCH_DELAY: f64 = 0.25; + #[derive(Clone)] pub struct TypeFontPreview { document: DocId, @@ -25,15 +30,33 @@ pub struct TypeFontPreview { pub(crate) fn follow_hover(app: &mut PhotocraftApp, ctx: &egui::Context) { let hover_id = egui::Id::new("type-font-hover"); + let since_id = egui::Id::new("type-font-hover-since"); let family = ctx.data_mut(|d| { let family = d.get_temp::(hover_id); d.remove::(hover_id); family }); if let Some(family) = family { - set(app, family); - } else if app.type_font_preview.is_some() { - clear(app); + let now = ctx.input(|i| i.time); + let since = ctx.data_mut(|d| match d.get_temp::<(String, f64)>(since_id) { + Some((f, t)) if f == family => t, + _ => { + d.insert_temp(since_id, (family.clone(), now)); + now + } + }); + let installed = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.has_family(&family); + let rested = now - since; + if installed || rested >= HOVER_FETCH_DELAY { + set(app, family); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(HOVER_FETCH_DELAY - rested)); + } + } else { + ctx.data_mut(|d| d.remove::<(String, f64)>(since_id)); + if app.type_font_preview.is_some() { + clear(app); + } } } @@ -135,3 +158,68 @@ fn preview_key(revision: u64, layer: LayerId, range: Option<[usize; 2]>, family: let h = family.bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0 ^ range_key, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); (h & (TAG - 1)) | TAG } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use serde_json::json; + + /// One frame at `time` (seconds) with the pointer on `family`'s row in the font menu (`None`: + /// on no row), as the menu reports it each frame. + fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, family: Option<&str>, time: f64) { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + if let Some(f) = family { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("type-font-hover"), f.to_string())); + } + follow_hover(app, ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn previewed(app: &PhotocraftApp) -> Option<&str> { + app.type_font_preview.as_ref().and_then(|p| p.family.as_deref()) + } + + fn app_with_type_layer() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap(); + app + } + + #[test] + fn a_served_family_is_asked_for_only_once_the_pointer_rests_on_its_row() { + // Families the host serves but hasn't sent (own names: the served catalog is process-wide). + let (a, b) = ("Served Hover Rest A", "Served Hover Rest B"); + let (fonts, _) = photocraft_text::served::parse_manifest(&format!("{a} | Regular | fonts/a/A.ttf\n{b} | Regular | fonts/b/B.ttf\n")); + photocraft_text::served::add_fonts(&fonts); + let mut app = app_with_type_layer(); + let ctx = egui::Context::default(); + + // Passing over rows asks for nothing: each new row starts the wait again. + frame(&mut app, &ctx, Some(a), 1.0); + frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY / 2.0); + frame(&mut app, &ctx, Some(b), 1.0 + HOVER_FETCH_DELAY); + frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY * 1.5); + assert_eq!(previewed(&app), None); + + // Resting on a row asks for the family and previews it. + frame(&mut app, &ctx, Some(a), 1.0 + HOVER_FETCH_DELAY * 3.0); + assert_eq!(previewed(&app), Some(a)); + + // Leaving the menu clears the preview, and the next hover waits again. + frame(&mut app, &ctx, None, 3.0); + assert_eq!(previewed(&app), None); + frame(&mut app, &ctx, Some(a), 3.1); + assert_eq!(previewed(&app), None); + } + + #[test] + fn an_installed_family_previews_at_once() { + let mut app = app_with_type_layer(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Some("JetBrains Mono"), 1.0); + assert_eq!(previewed(&app), Some("JetBrains Mono")); + } +} diff --git a/crates/ui-egui/src/type_panels_ui.rs b/crates/ui-egui/src/type_panels_ui.rs index 86ef663209..65e28e04be 100644 --- a/crates/ui-egui/src/type_panels_ui.rs +++ b/crates/ui-egui/src/type_panels_ui.rs @@ -373,6 +373,8 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s let defined = |attrs: &Value, k: &str| attrs.get(k).is_some(); // Preferences ▸ Type ▸ Font Preview Size sizes the family picker's samples. let preview = app.session.prefs().type_.font_preview; + // The fonts the pickers hoist to the top (Preferences ▸ Type ▸ Recent Fonts). + let recents = app.ui.recent_fonts.clone(); // Preferences ▸ Units & Rulers ▸ Type: the model stores points; the fields may show pixels // (by the document's resolution) or millimetres. let (type_unit, unit_label) = crate::type_tool::type_unit(app); @@ -399,7 +401,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s ui.add_enabled_ui(on, |ui| match kind { "font" => { let mut f = v.as_str().unwrap_or("").to_string(); - if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview) { + if family_picker(ui, &format!("style-font-{paragraph}-{id}"), &mut f, preview, &recents) { set = Some(json!({ panel: f })); } } @@ -518,12 +520,12 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s } /// Searchable family combo box. -fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview) -> bool { +fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool { if !crate::type_tool::fonts_ready(ui, 140.0) { return false; } let mut shown = if current.is_empty() { photocraft_text::fonts::DEFAULT_FAMILY.to_string() } else { current.clone() }; - let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview); + let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families(), preview, recents); if changed { *current = shown; } @@ -617,7 +619,8 @@ pub fn glyphs_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Font family + style. ui.horizontal(|ui| { let mut f = family.clone(); - if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview) { + if family_picker(ui, "glyph-family", &mut f, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) { + app.note_recent_font(&f); app.ui.type_panels.glyph_family = f; app.ui.type_panels.glyph_style = tl!("Regular").into(); } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 0b7ba0e157..0bfda45226 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -15,7 +15,7 @@ use serde_json::json; use crate::PhotocraftApp; use crate::canvas::ViewXform; -use crate::state::TextEdit; +use crate::state::{BoxResize, TextEdit}; pub const PLACEHOLDER: &str = "Lorem Ipsum"; @@ -114,8 +114,18 @@ pub fn edit_active(app: &mut PhotocraftApp) -> Result<(), String> { commit(app); let key = session_key(app); begin_edit(app, id, &key)?; - app.ui.text_edit = - Some(TextEdit { layer: id.0, caret: n, anchor: 0, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id.0, + document: active_doc_id(app), + caret: n, + anchor: 0, + session: key, + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); } else if let Some(ed) = app.ui.text_edit.as_mut() { ed.anchor = 0; ed.caret = n; @@ -143,8 +153,18 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { return false; } let off = hit_offset(app, id, x, y); - app.ui.text_edit = - Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id.0, + document: active_doc_id(app), + caret: off, + anchor: off, + session: key, + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); let vertical = app.session.active().and_then(|st| text_layer(&st.doc, id)).is_some_and(|t| t.orientation == photocraft_doc::text::Orientation::Vertical); if !previous_tool.is_type() { app.ui.text_edit_return_tool = Some(previous_tool); @@ -163,6 +183,14 @@ fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { let Some((l, aff, text)) = layout(app, id) else { return 0 }; let (tx, ty) = to_text(&aff, x, y); + // Type on a path: the point's place along the path is the position in the (single) line. + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); + let (tx, ty) = match shape { + Some(photocraft_doc::text::TextShape::Path { path, start, flip }) => { + photocraft_text::warp::arc_length_at(&path, flip, f64::from(tx), f64::from(ty)).map_or((tx, ty), |s| ((s - f64::from(start)) as f32, -1.0)) + } + _ => (tx, ty), + }; photocraft_text::hit_char(&l, &text, tx, ty).0 } @@ -212,18 +240,23 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64 .map(|i| i as u8) } -/// Drag handle `i` to the document point: the dragged edges follow it, the others stay put. -fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { - let id = LayerId(ed.layer); - let Some((bx, by, w, h)) = box_shape(app, id) else { return }; - let Some((_, aff, _)) = layout(app, id) else { return }; - let (px, py) = to_text(&aff, x, y); - let (l, r, t, b) = handle_sides(i); +/// Drag the handle of `r` (the box and transform snapshotted at the press) to the document +/// point: the dragged edges follow it, the others stay put. With ⌥ the opposite edges mirror +/// the drag about the centre the box started with, so the block grows symmetrically instead of +/// from one side — pressing or releasing ⌥ mid-drag switches that on and off as if it had been +/// held from the start. +fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, r: BoxResize, x: f64, y: f64, alt: bool) { + let (px, py) = to_text(&r.aff, x, y); + let (l, rw, t, b) = handle_sides(r.handle); + let (bx, by, w, h) = (r.shape[0], r.shape[1], r.shape[2], r.shape[3]); let (mut x0, mut y0, mut x1, mut y1) = (bx, by, bx + w, by + h); + // The centre the ⌥ mirror turns about: the box as it stood at the press (every move + // recomputes from that snapshot, so the centre holds for the whole drag). + let (cx, cy) = (bx + w / 2.0, by + h / 2.0); if l { x0 = px.min(x1 - MIN_BOX); } - if r { + if rw { x1 = px.max(x0 + MIN_BOX); } if t { @@ -232,14 +265,34 @@ fn resize_box(app: &mut PhotocraftApp, ed: &TextEdit, i: u8, x: f64, y: f64) { if b { y1 = py.max(y0 + MIN_BOX); } + if alt { + // The mirror keeps the centre fixed: the dragged edge clamps at MIN_BOX/2 from it, and + // the opposite edge follows, so the block never inverts below the minimum. + if l { + x0 = px.min(cx - MIN_BOX / 2.0); + x1 = 2.0 * cx - x0; + } + if rw { + x1 = px.max(cx + MIN_BOX / 2.0); + x0 = 2.0 * cx - x1; + } + if t { + y0 = py.min(cy - MIN_BOX / 2.0); + y1 = 2.0 * cy - y0; + } + if b { + y1 = py.max(cy + MIN_BOX / 2.0); + y0 = 2.0 * cy - y1; + } + } // type.edit places the box's top-left at the given document point. - let o = aff.apply(Point::new(f64::from(x0), f64::from(y0))); + let o = r.aff.apply(Point::new(f64::from(x0), f64::from(y0))); let _ = app.run("type.edit", json!({"layer": ed.layer, "box": [o.x, o.y, x1 - x0, y1 - y0], "coalesce": ed.session})); } /// True when document point (`x`, `y`) lies inside the paragraph box of type layer `id` (false /// for point text). -fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { +pub(crate) fn inside_box(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> bool { let Some((bx, by, w, h)) = box_shape(app, id) else { return false }; let Some((_, aff, _)) = layout(app, id) else { return false }; let (tx, ty) = to_text(&aff, x, y); @@ -251,8 +304,11 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie if let Some(ed) = app.ui.text_edit.clone() { let id = LayerId(ed.layer); if let Some(i) = box_handle_at(app, id, x, y) { - if let Some(e) = app.ui.text_edit.as_mut() { - e.resize = Some(i); + // Snapshot the box and transform now: every move recomputes from them, so ⌥ held + // or released mid-drag mirrors about the centre the drag started with. + let snap = box_shape(app, id).zip(layout(app, id)).map(|((bx, by, w, h), (_, aff, _))| BoxResize { handle: i, shape: [bx, by, w, h], aff }); + if let (Some(snap), Some(e)) = (snap, app.ui.text_edit.as_mut()) { + e.resize = Some(snap); } return true; } @@ -285,18 +341,28 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie return true; } let off = hit_offset(app, id, x, y); - app.ui.text_edit = - Some(TextEdit { layer: id.0, caret: off, anchor: off, session: key, created: false, mask: false, dragging: true, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id.0, + document: active_doc_id(app), + caret: off, + anchor: off, + session: key, + created: false, + mask: false, + dragging: true, + resize: None, + preedit: None, + }); app.ui.text_edit_return_tool = None; return true; } false } -pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64) { +pub fn pointer_move(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) { let Some(ed) = app.ui.text_edit.clone() else { return }; - if let Some(i) = ed.resize { - resize_box(app, &ed, i, x, y); + if let Some(r) = ed.resize { + resize_box(app, &ed, r, x, y, mods.alt); return; } if ed.dragging { @@ -322,21 +388,17 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { // Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). The Type // Mask tools start empty, as Photoshop's do. let text = if !mask && app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" }; - let mut p = json!({ - "text": text, - "orientation": if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" }, - "font": o.type_font, - "fontStyle": o.type_style, - "size": o.type_size, - "align": o.type_align, - "color": hex(app.session.tools.foreground), - }); + let mut p = default_style_params(app); + p["text"] = json!(text); + p["orientation"] = json!(if app.ui.tool.is_vertical_type() { "vertical" } else { "horizontal" }); if w >= min && h >= min { p["box"] = json!([start[0].min(end[0]).round(), start[1].min(end[1]).round(), w.round(), h.round()]); } else { p["x"] = json!(start[0].round()); p["y"] = json!(start[1].round()); } + // A click on a shown path starts type on that path, from the point clicked (Photoshop). + let on_path = if p.get("box").is_none() { path_at(app, start[0], start[1]) } else { None }; let key = session_key(app); p["coalesce"] = json!(key); if let Ok(v) = app.run("type.create", p) @@ -345,6 +407,14 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { if o.type_aa != "sharp" { let _ = app.run("type.edit", json!({"layer": id, "antialias": o.type_aa, "coalesce": key})); } + if let Some(mut q) = on_path { + q["layer"] = json!(id); + q["at"] = json!([start[0], start[1]]); + q["coalesce"] = json!(key); + if let Err(e) = app.run("type.attachToPath", q) { + app.ui.status = e; + } + } // A Type Mask's text is a hidden working layer: the canvas shows the mask overlay // instead, and committing turns it into the selection. if mask { @@ -353,15 +423,77 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { // Like Photoshop: the placeholder is selected, so typing replaces it. let n = text.chars().count(); app.ui.caret_blink_time = app.last_frame_time; - app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, mask, dragging: false, resize: None, preedit: None }); + app.ui.text_edit = Some(TextEdit { + layer: id, + document: active_doc_id(app), + caret: n, + anchor: 0, + session: key, + created: true, + mask, + dragging: false, + resize: None, + preedit: None, + }); app.ui.text_edit_return_tool = None; } } +/// The shown path a Type tool click at document point (`x`, `y`) puts type on, as +/// `type.attachToPath` params naming it (`shape`, `subpath`): the active shape layer's path, else +/// the work path, within a few screen pixels, as in Photoshop. Only the horizontal Type tool +/// starts path type for now; vertical type on a path is not laid out yet. +pub(crate) fn path_at(app: &PhotocraftApp, x: f64, y: f64) -> Option { + if app.ui.tool != crate::state::Tool::Type || app.ui.text_edit.is_some() || !app.ui.view.shows(app.ui.view.show.target_path) { + return None; + } + let tol = f64::from(6.0 / app.point_zoom().max(0.01)); + let hit = |p: &photocraft_doc::vector::Path| { + let s = match photocraft_vector::edit::hit(p, Point::new(x, y), tol, &[])? { + photocraft_vector::edit::Hit::Anchor([s, _]) | photocraft_vector::edit::Hit::Segment([s, _], _) => s, + photocraft_vector::edit::Hit::Handle(..) => return None, + }; + p.subpaths.get(s).filter(|sp| sp.knots.len() >= 2).map(|_| s) + }; + if let Some((id, p)) = crate::vector_ui::active_shape_path(app) + && let Some(s) = hit(&p) + { + return Some(json!({"shape": id, "subpath": s})); + } + let work = app.session.active()?.doc.work_path.as_ref()?; + hit(work).map(|s| json!({"subpath": s})) +} + +/// The Type tool's pointer over a path it can follow: Photoshop's I-beam crossed by a dotted +/// wavy line, drawn on the canvas. +pub(crate) fn path_cursor(painter: &egui::Painter, at: Pos2) -> egui::CursorIcon { + let beam = |c: Color32, w: f32| { + let s = Stroke::new(w, c); + painter.line_segment([at + egui::vec2(0.0, -8.0), at + egui::vec2(0.0, 8.0)], s); + for dy in [-8.0, 8.0] { + painter.line_segment([at + egui::vec2(-3.0, dy), at + egui::vec2(3.0, dy)], s); + } + }; + beam(Color32::WHITE, 3.0); + beam(Color32::BLACK, 1.0); + // The wave: short dashes along a sine through the beam's lower half. + let wave = |t: f32| at + egui::vec2(-9.0 + 18.0 * t, 5.0 - 3.0 * t + 2.0 * (t * std::f32::consts::TAU * 1.5).sin()); + for i in (0..12).step_by(2) { + let (a, b) = (wave(i as f32 / 12.0), wave((i + 1) as f32 / 12.0)); + painter.line_segment([a, b], Stroke::new(3.0, Color32::WHITE)); + painter.line_segment([a, b], Stroke::new(1.0, Color32::BLACK)); + } + egui::CursorIcon::None +} + fn session_key(app: &mut PhotocraftApp) -> String { format!("type-{}", app.ui.alloc_id()) } +fn active_doc_id(app: &PhotocraftApp) -> Option { + app.session.active().map(|st| st.doc.id.0) +} + fn current_text(app: &PhotocraftApp, id: LayerId) -> Option { Some(text_layer(&app.session.active()?.doc, id)?.text.clone()) } @@ -781,6 +913,7 @@ fn line_edge(app: &mut PhotocraftApp, id: LayerId, caret: usize, end: bool) -> u /// Keyboard input while editing. Returns true when a type edit session is active (single-key /// tool shortcuts must then be skipped). Handled events are removed from the frame's input. pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + end_if_left(app); crate::type_transform::cancel_stale(app); if !ctx.input(|i| i.focused) { crate::type_transform::cancel_drag(app); @@ -981,6 +1114,40 @@ pub fn commit(app: &mut PhotocraftApp) { /// selection with ⇧, subtracts them with ⌥ and intersects with ⇧⌥ (Photoshop); with none it /// replaces the selection. pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { + let current = app.session.active_index(); + let Some(home) = app.ui.text_edit.as_ref().map(|ed| edit_home(app, ed)) else { + return commit_here(app, mods); + }; + match home { + Some(home) if Some(home) != current => { + // An edit left in a background document commits there, in its own history (#6483). + app.session.set_active(home); + commit_here(app, mods); + if let Some(i) = current { + app.session.set_active(i); + } + } + Some(_) => commit_here(app, mods), + // Its document closed: there is nothing to commit into, and the layer id may name a + // layer of another document. + None => { + crate::type_transform::reset(app); + app.ui.text_edit = None; + app.ui.text_edit_return_tool = None; + } + } +} + +/// Index of the document an edit belongs to; `None` once it is closed. +fn edit_home(app: &PhotocraftApp, ed: &TextEdit) -> Option { + match ed.document { + Some(d) => app.session.documents().iter().position(|st| st.doc.id.0 == d), + None => app.session.active_index(), + } +} + +/// [`commit_with`] in the active document, which is the edit's own. +fn commit_here(app: &mut PhotocraftApp, mods: egui::Modifiers) { if app.ui.tool.is_type() { crate::type_transform::finish(app); } @@ -1015,9 +1182,37 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { } } +/// Commit the edit once its layer is no longer the one worked on (#6483): another layer became +/// active (a Layers panel menu, the canvas layer picker, automation) or another document did (a +/// tab, File ▸ New/Open). Photoshop commits then; left open, the session took single-key +/// shortcuts as typing and typed into whichever document was active. +pub fn end_if_left(app: &mut PhotocraftApp) { + let Some(ed) = app.ui.text_edit.as_ref() else { return }; + let layer = LayerId(ed.layer); + if edit_home(app, ed).is_none_or(|h| Some(h) != app.session.active_index()) { + return commit(app); + } + let Some(st) = app.session.active() else { return }; + if st.active_layer == Some(layer) { + return; + } + // The selection just made stands: ending an empty Type Mask undoes its session, and undo + // restores the selection from before it. + let chosen = (st.active_layer, st.selected_layers.clone(), st.layer_anchor); + commit(app); + let moved = app.session.active().is_some_and(|st| (st.active_layer, &st.selected_layers, st.layer_anchor) != (chosen.0, &chosen.1, chosen.2)); + if moved { + let _ = app.session.restore_layer_selection(chosen.0, &chosen.1, chosen.2); + } +} + /// Selection highlight, caret and text frame over the canvas. pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(ed) = app.ui.text_edit.clone() else { return }; + // Until it commits, an edit in a document no longer active draws nothing over this one. + if edit_home(app, &ed) != app.session.active_index() { + return; + } if ed.dragging { app.ui.caret_blink_time = painter.ctx().input(|i| i.time); } @@ -1032,10 +1227,24 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let p = aff.apply(Point::new(x as f64, y as f64)); xf.to_screen(p.x as f32, p.y as f32) }; + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); + // Type on a path: the caret, selection and underline follow the curve the glyphs sit on. + let bend = shape.as_ref().and_then(photocraft_text::warp::Warp::on_path); // Line space (lines, clusters, carets) → screen: vertical type turns it 90° clockwise. let scr = |x: f32, y: f32| -> Pos2 { let (x, y) = l.to_text(x, y); - scr_t(x, y) + match &bend { + Some(w) => { + let (x, y) = w.apply(f64::from(x), f64::from(y)); + scr_t(x as f32, y as f32) + } + None => scr_t(x, y), + } + }; + // A line-space run from `x0` to `x1` at height `y`: sampled, so it bends with path type. + let run = |x0: f32, x1: f32, y: f32| -> Vec { + let n = if bend.is_some() { ((x1 - x0) / 4.0).ceil().clamp(1.0, 512.0) as usize } else { 1 }; + (0..=n).map(|i| scr(x0 + (x1 - x0) * i as f32 / n as f32, y)).collect() }; // Tell the OS where the caret is: this is what enables the IME and places its candidate window. { @@ -1049,7 +1258,6 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX // Frame: paragraph text shows its box with handles; point text an underline per line. let mods = crate::workspace_ui::sticky_mods(app, painter.ctx().input(|i| i.modifiers)); let has_transform = crate::type_transform::draw(app, painter, xf, mods); - let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { @@ -1068,7 +1276,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX for ln in &l.lines { // Horizontal: just under the baseline; vertical: the column's centre line. let y = if l.vertical { ln.baseline } else { ln.baseline + ln.descent * 0.25 }; - painter.line_segment([scr(ln.x0.min(0.0), y), scr(ln.x1.max(ln.x0 + 1.0), y)], Stroke::new(1.0, t.accent.gamma_multiply(0.8))); + painter.add(egui::Shape::line(run(ln.x0.min(0.0), ln.x1.max(ln.x0 + 1.0), y), Stroke::new(1.0, t.accent.gamma_multiply(0.8)))); } } } @@ -1081,7 +1289,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX let (x0, x1) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), (p, q)| (lo.min(*p), hi.max(*q))); if x0 < x1 { let y = ln.baseline + ln.descent * 0.5; - painter.line_segment([scr(x0, y), scr(x1, y)], Stroke::new(1.5, t.accent)); + painter.add(egui::Shape::line(run(x0, x1, y), Stroke::new(1.5, t.accent))); } } } @@ -1098,8 +1306,15 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX x1 = x1.max(ln.x1 + (ln.ascent + ln.descent) * 0.25); x0 = x0.min(ln.x1); } - if x0 < x1 { - let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent); + let (top, bot) = (ln.baseline - ln.ascent, ln.baseline + ln.descent); + if bend.is_some() { + // On a path each glyph turns on its own, so each gets its own highlight. + // Glyphs past the end of the path are not drawn, so neither is their highlight. + let shown = |p: f32, q: f32| bend.as_ref().is_some_and(|w| w.place_glyph(f64::from(p + q) / 2.0) != photocraft_text::warp::Placement::Hidden); + for (p, q) in xs.into_iter().filter(|&(p, q)| shown(p, q)) { + painter.add(egui::Shape::convex_polygon(vec![scr(p, top), scr(q, top), scr(q, bot), scr(p, bot)], fill, Stroke::NONE)); + } + } else if x0 < x1 { painter.add(egui::Shape::convex_polygon(vec![scr(x0, top), scr(x1, top), scr(x1, bot), scr(x0, bot)], fill, Stroke::NONE)); } } @@ -1253,8 +1468,16 @@ fn localized_families() -> Arc> { /// The font menu's rows: each family with the label it shows (its localized name when `labels` /// has one, else the family), matching `query` (lowercase) by either name. With localized labels -/// the rows sort by label, so the menu stays alphabetical as shown. -fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap>, query: &str) -> Vec<(&'a String, &'a str)> { +/// the rows sort by label, so the menu stays alphabetical as shown. As in Photoshop, recently +/// used families also head the menu in their own group, most recent first (Preferences ▸ Type ▸ +/// Recent Fonts), and keep their alphabetical place below it; a query shows each match once. +/// Returns the rows and how many of them form the recent group. +fn font_menu_rows<'a>( + families: &'a [String], + labels: Option<&'a HashMap>, + query: &str, + recents: &[String], +) -> (Vec<(&'a String, &'a str)>, usize) { let label = |f: &'a String| labels.and_then(|l| l.get(f)).map_or(f.as_str(), String::as_str); let mut rows: Vec<(&String, &str)> = families .iter() @@ -1264,7 +1487,12 @@ fn font_menu_rows<'a>(families: &'a [String], labels: Option<&'a HashMap = recents.iter().filter_map(|r| rows.iter().find(|(f, _)| *f == r).copied()).collect(); + let n = recent.len(); + (recent.into_iter().chain(rows).collect(), n) } fn weight_name(w: f32) -> &'static str { @@ -1438,8 +1666,8 @@ pub(crate) fn fonts_ready(ui: &mut egui::Ui, width: f32) -> bool { } /// Searchable font-family combo box. -fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview) -> bool { - family_picker_in(ui, "type-font", current, width, &families(), preview) +fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32, preview: photocraft_engine::prefs::FontPreview, recents: &[String]) -> bool { + family_picker_in(ui, "type-font", current, width, &families(), preview, recents) } /// Maximum height of the font menu. @@ -1447,8 +1675,15 @@ const FONT_MENU_HEIGHT: f32 = 460.0; /// [`font_picker`] over a given family list (tests pass their own). #[cfg(test)] -fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families: &[String], preview: photocraft_engine::prefs::FontPreview) -> bool { - family_picker_in(ui, "type-font", current, width, families, preview) +fn font_picker_in( + ui: &mut egui::Ui, + current: &mut String, + width: f32, + families: &[String], + preview: photocraft_engine::prefs::FontPreview, + recents: &[String], +) -> bool { + family_picker_in(ui, "type-font", current, width, families, preview, recents) } /// Shared by the Type options, Character, style and Glyphs panels. Selection changes still @@ -1460,12 +1695,15 @@ pub(crate) fn family_picker_in( width: f32, families: &[String], preview: photocraft_engine::prefs::FontPreview, + recents: &[String], ) -> bool { let mut changed = false; // A font picked from the open menu (click or Enter): the field keeps the focus afterwards. let mut picked = false; let search_id = ui.id().with(("font-search", salt)); let focus_id = search_id.with("focus"); + let recents_id = search_id.with("recents"); + let row_id = search_id.with("row"); // The list opens on the press, so a press-drag-release picks a font too (#2735). let (press, close) = crate::press_menu::PressCombo::before(ui, salt); let labels: Option>> = ui.ctx().data(|d| d.get_temp(font_labels_id())); @@ -1504,10 +1742,21 @@ pub(crate) fn family_picker_in( d.insert_temp(search_id, q.clone()); }); let ql = q.to_lowercase(); - let rows = font_menu_rows(families, labels, &ql); + // The recent group stays as it was when the menu opened: each arrow step applies (and so + // records) a family, and moving it to the top would send the next step back up. + let open_recents: Vec = ui.data_mut(|d| d.get_temp_mut_or_insert_with(recents_id, || recents.to_vec()).clone()); + let (rows, n_recent) = font_menu_rows(families, labels, &ql, &open_recents); + // A recent family is listed twice. The current family's row is the one the last arrow + // selected while it still shows that family, else its alphabetical copy, so the arrows + // step through the alphabet as on the closed field. + let mut selected = ui + .data(|d| d.get_temp::(row_id)) + .filter(|&i| rows.get(i).is_some_and(|(f, _)| *f == current)) + .or_else(|| rows.iter().skip(n_recent).position(|(f, _)| *f == current).map(|i| i + n_recent)) + .or_else(|| rows.iter().position(|(f, _)| *f == current)); + let mut stepped = None; if down || up { - let index = rows.iter().position(|(f, _)| *f == current); - let next = match index { + let next = match selected { Some(i) if down => i.saturating_add(1).min(rows.len().saturating_sub(1)), Some(i) => i.saturating_sub(1), None if down => 0, @@ -1518,6 +1767,9 @@ pub(crate) fn family_picker_in( current.clone_from(f); // A served family not fetched yet: start now, as a click does. photocraft_text::served::request(f); + stepped = Some(next); + selected = Some(next); + ui.data_mut(|d| d.insert_temp(row_id, next)); } } let accept = enter && rows.iter().any(|(f, _)| *f == current); @@ -1529,43 +1781,52 @@ pub(crate) fn family_picker_in( let preview = crate::font_preview::sample(preview); let row_h = preview.map_or(28.0, |(_, [_, h])| (h + 4.0).max(28.0)); + // Wrap the font rows in a ScrollArea so only the list scrolls: the search field above + // stays fixed at the top of the popup. let mut hovered: Option = None; - for (f, label) in rows { - // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays - // out its frame's stroke only once hovered or selected, so with the theme's stroke the - // row under the pointer (and the selected one) grew and pushed the rows below down: - // the list jumped as the pointer moved (#7406). - let mut button = egui::Button::selectable(f == current, label).truncate().stroke(egui::Stroke::NONE); - if let Some((size, [_, h])) = preview { - button = - button.right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, (f, label)) in rows.into_iter().enumerate() { + if i == n_recent && n_recent > 0 { + ui.separator(); + } + // Highlighted by its fill alone, as Photoshop's font menu is. A selectable button lays + // out its frame's stroke only once hovered or selected, so with the theme's stroke the + // row under the pointer (and the selected one) grew and pushed the rows below down: + // the list jumped as the pointer moved (#7406). One row only, though a recent + // family is listed twice. + let mut button = egui::Button::selectable(selected == Some(i), label).truncate().stroke(egui::Stroke::NONE); + if let Some((size, [_, h])) = preview { + button = button + .right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(crate::font_preview::slot_width(ui.ctx(), f, size), h))); + } + let response = ui.add_sized([330.0, row_h], button); + if stepped == Some(i) { + response.scroll_to_me(Some(egui::Align::Center)); + } + if let Some((size, _)) = preview + && ui.is_rect_visible(response.rect) + { + crate::font_preview::paint(ui, f, response.rect, size); + } + if response.hovered() { + hovered = Some(f.clone()); + } + if press.chosen(ui, &response) { + *current = f.clone(); + // A served family not fetched yet: start now, before any text needs it. + photocraft_text::served::request(f); + changed = true; + picked = true; + ui.data_mut(|d| d.remove::(search_id)); + hovered = None; + ui.close(); + } } - let response = ui.add_sized([330.0, row_h], button); - if (down || up) && f == current { - response.scroll_to_me(Some(egui::Align::Center)); - } - if let Some((size, _)) = preview - && ui.is_rect_visible(response.rect) - { - crate::font_preview::paint(ui, f, response.rect, size); - } - if response.hovered() { - hovered = Some(f.clone()); - } - if press.chosen(ui, &response) { - *current = f.clone(); - // A served family not fetched yet: start now, before any text needs it. - photocraft_text::served::request(f); - changed = true; - picked = true; - ui.data_mut(|d| d.remove::(search_id)); - hovered = None; - ui.close(); - } - } + }); // egui measures a popup only when it opens and never lets it grow back, so a filtered // list would leave the menu short after the query is cleared (#1369). Keep the unfiltered - // height while searching. + // height while searching. Measured after the rows: before them `min_rect` only holds the + // search field, and the cache would shrink the menu to it. if ql.is_empty() { let h = ui.min_rect().height(); ui.data_mut(|d| d.insert_temp(height_id, h)); @@ -1576,7 +1837,11 @@ pub(crate) fn family_picker_in( }); press.after(&response.response); if !egui::ComboBox::is_open(ui.ctx(), response.response.id) { - ui.data_mut(|d| d.remove::(focus_id)); + ui.data_mut(|d| { + d.remove::(focus_id); + d.remove::>(recents_id); + d.remove::(row_id); + }); } let hovered_font = response.inner.unwrap_or_default(); @@ -1598,9 +1863,10 @@ pub(crate) fn family_picker_in( // the frame. Cancel it, so the arrow only steps the font. ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } - // The wheel's step: a negative notch is the next family. + // The wheel's step: a negative notch is the next family. Steps use a stable order (no + // recent group), so recording the picked family cannot make the next step jump back. let notches = f32::from(u8::from(up)) - f32::from(u8::from(down)); - let rows = font_menu_rows(families, labels, ""); + let (rows, _) = font_menu_rows(families, labels, "", &[]); if let Some(i) = rows.iter().position(|(f, _)| *f == current) && let Some(n) = wheel_family_index(i, rows.len(), notches) && let Some((f, _)) = rows.get(n) @@ -1620,7 +1886,10 @@ pub(crate) fn family_picker_in( ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); } let notches = crate::widgets::wheel_notches_of(ui, response.response.id.with("family-wheel"), hovered, 1.0); - let rows = font_menu_rows(families, labels, ""); + // The wheel steps in a stable order: a recent group holding the family it just picked + // would change the order between notches and can jump back to the previous family. + // The open menu still shows the recent families first. + let (rows, _) = font_menu_rows(families, labels, "", &[]); if let Some(i) = rows.iter().position(|(f, _)| *f == current) && let Some(n) = wheel_family_index(i, rows.len(), notches) && let Some((f, _)) = rows.get(n) @@ -1722,9 +1991,139 @@ fn drag_key(ctx: &egui::Context) -> Option { Some(format!("type-drag-{}-{}", id.value(), t.to_bits())) } -/// Apply only the supplied properties to the text-edit scope or selected type layers. +/// The styles a new Type-tool layer will receive. Use the same command parameters for the +/// panels and creation so defaults also survive action recording/replay. The older options-bar +/// fields remain authoritative, and text colour continues to follow the foreground colour. +fn default_style_params(app: &PhotocraftApp) -> serde_json::Value { + let o = &app.ui.tool_options; + let mut p = json!({}); + // Settings are editable JSON: only recognized formatting keys may reach type.create. + // In particular a stray `text`, `box`, or `coalesce` key must not become a create parameter. + let (mut c, mut para) = (photocraft_doc::text::CharStyle::default(), photocraft_doc::text::ParagraphStyle::default()); + for (key, value) in &o.type_style_defaults { + // These belong to the existing options-bar fields. Apart from being redundant, + // parsing a saved font key here would wait for the background font scan. + if matches!(key.as_str(), "font" | "family" | "fontStyle" | "weight" | "italic" | "postscriptName") { + continue; + } + let one = json!({ (key): value }); + if !finite_style_value(value) + || photocraft_engine::type_cmds::check_size_tracking(&one).is_err() + || photocraft_engine::type_cmds::check_kerning(&one).is_err() + { + continue; + } + if photocraft_engine::type_cmds::apply_char_props(&mut c, &one) | photocraft_engine::type_cmds::apply_para_props(&mut para, &one) { + p[key] = value.clone(); + } + } + p["font"] = json!(o.type_font); + p["fontStyle"] = json!(o.type_style); + p["size"] = json!(o.type_size); + p["align"] = json!(o.type_align); + p["direction"] = json!(o.type_direction); + p["color"] = json!(hex(app.session.tools.foreground)); + p +} + +/// Style numbers become f32 in the engine. JSON itself permits finite f64 values too large +/// for that conversion; a hand-edited setting must not inject infinity into a new layer. +/// Style collections (features, axes, colour) contain only scalar values, so no recursion. +fn finite_style_value(value: &serde_json::Value) -> bool { + let scalar = |v: &serde_json::Value| match v { + serde_json::Value::Number(n) => n.as_f64().is_some_and(|n| (n as f32).is_finite()), + serde_json::Value::Array(_) | serde_json::Value::Object(_) => false, + _ => true, + }; + match value { + serde_json::Value::Array(v) => v.iter().all(scalar), + serde_json::Value::Object(v) => v.values().all(scalar), + v => scalar(v), + } +} + +fn default_styles(app: &PhotocraftApp) -> (photocraft_doc::text::CharStyle, photocraft_doc::text::ParagraphStyle) { + let (mut c, mut para) = app.session.type_defaults.clone().unwrap_or_default(); + let mut p = default_style_params(app); + // The saved panels can appear on the very first frame. Style parsing resolves the named + // font through shared(), which waits for the font scan; display the saved names meanwhile, + // and let fonts_ready/axis_sliders expose the font controls when try_shared is ready. + if photocraft_text::try_shared().is_none() { + c.font_family = app.ui.tool_options.type_font.clone(); + c.font_style = app.ui.tool_options.type_style.clone(); + c.postscript_name = None; + if let Some(p) = p.as_object_mut() { + p.remove("font"); + p.remove("fontStyle"); + } + } + photocraft_engine::type_cmds::apply_char_props(&mut c, &p); + photocraft_engine::type_cmds::apply_para_props(&mut para, &p); + (c, para) +} + +/// Change tool defaults without creating a document edit. The engine owns the style schema; +/// keep its property names rather than maintaining another style parser in the shell. +fn apply_defaults(app: &mut PhotocraftApp, props: &serde_json::Value) { + let Some(props) = props.as_object() else { return }; + let o = &mut app.ui.tool_options; + for (key, value) in props { + match key.as_str() { + "font" => { + if let Some(v) = value.as_str() { + o.type_font = v.into(); + } + } + "fontStyle" => { + if let Some(v) = value.as_str() { + o.type_style = v.into(); + // A newly chosen named style replaces a weight-axis setting, as it does + // in apply_char_props. Other axes retain their independently chosen values. + if let Some(axes) = o.type_style_defaults.get_mut("variations").and_then(serde_json::Value::as_object_mut) { + axes.remove("wght"); + } + } + } + "size" => { + if let Some(v) = value.as_f64() { + o.type_size = v as f32; + } + } + "align" => { + if let Some(v) = value.as_str() { + o.type_align = v.into(); + } + } + "direction" => { + if let Some(v) = value.as_str() { + o.type_direction = v.into(); + } + } + "metricsAsShown" => {} // Defaults have no layer transform. + "features" | "variations" => { + if let Some(patch) = value.as_object() { + let old = o.type_style_defaults.entry(key.clone()).or_insert_with(|| json!({})); + if let Some(old) = old.as_object_mut() { + old.extend(patch.clone()); + } else { + *old = value.clone(); + } + } + } + _ => { + o.type_style_defaults.insert(key.clone(), value.clone()); + } + } + } +} + +/// Apply only the supplied properties to the text-edit scope or selected type layers; without +/// a type target, prepare the defaults for the next layer instead. fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value) { - let Some(mut p) = formatting_params(app) else { return }; + let Some(mut p) = formatting_params(app) else { + apply_defaults(app, &props); + return; + }; if let Some(props) = props.as_object() { for (key, value) in props { p[key] = value.clone(); @@ -1798,7 +2197,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // Font family and style menus (one placeholder in their place while the fonts load). if fonts_ready(ui, 170.0 + ui.spacing().item_spacing.x + 110.0) { - if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview) { + if font_picker(ui, &mut fam, 170.0, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) { + app.note_recent_font(&fam); app.ui.tool_options.type_font = fam.clone(); let st = styles(&fam); style = if st.contains(&style) { style } else { st.first().cloned().unwrap_or_else(|| "Regular".into()) }; @@ -1812,8 +2212,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { apply(app, ui.ctx(), json!({"fontStyle": style})); } } - let (r, _) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); + let (r, icon) = ui.allocate_exact_size(egui::vec2(18.0, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, "type", 13.0, t.icon); + icon.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tl!("Font size"))); + // Photoshop's "T" scrubs the size field beside it, like the Character panel's icons (#6437). + crate::widgets::scrub_option_icon(ui, &icon, tl!("Font size")); let (unit, label) = type_unit(app); let mut display = size * unit; if crate::widgets::value_field(ui, &mut display, unit..=1296.0 * unit, label, 66.0).changed() { @@ -1850,6 +2253,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } ui.spacing_mut().item_spacing.x = 8.0; crate::widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 2.0; + for (key, tip, d) in direction_items() { + if direction_button(ui, d, o.type_direction == key, tip).clicked() { + app.ui.tool_options.type_direction = key.into(); + apply(app, ui.ctx(), json!({"direction": key})); + } + } + ui.spacing_mut().item_spacing.x = 8.0; + crate::widgets::vline(ui, 22.0); // Text colour swatch with a picker popup. let c = shown .as_ref() @@ -1868,6 +2280,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if resp.clicked() { open_color_picker(app, [c[0], c[1], c[2]]); } + // Create warped text, after the swatch as in Photoshop (#5911): the Type › Warp Text… dialog. + let can_warp = crate::menus::is_enabled(app, "type.warpText"); + if ui.add_enabled_ui(can_warp, |ui| crate::icons::button(ui, "text-warp", 24.0, false, tl!("Create warped text"))).inner.clicked() + && let Err(e) = crate::menus::invoke(app, &ui.ctx().clone(), "type.warpText", json!({})) + { + app.ui.status = e; + app.ui.status_error = true; + } if app.ui.text_edit.is_some() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); @@ -2005,7 +2425,7 @@ fn icon_label(ui: &mut egui::Ui, icon: &str, tip: &str) { let (r, resp) = ui.allocate_exact_size(egui::vec2(crate::props_layout::LABEL_W, 22.0), egui::Sense::hover()); crate::icons::paint(ui, r, icon, 12.0, t.text_dim); // Photoshop's Character panel icons scrub the field beside them. - crate::widgets::scrub_label(ui, &resp.on_hover_text(tip)); + crate::widgets::scrub_label(ui, &resp.on_hover_text(tl!(tip))); } /// Labelled numeric field (Photoshop's icon + value pairs). Returns the new value when edited. @@ -2053,6 +2473,47 @@ fn align_glyph(p: &egui::Painter, r: egui::Rect, a: photocraft_doc::text::TextAl } } +/// Paragraph direction choices for the Paragraph panel and the options bar (TYPE-218-2): the +/// `direction` value `type.setStyle` takes, the tooltip, and the direction it sets. +fn direction_items() -> [(&'static str, &'static str, photocraft_doc::text::TextDirection); 3] { + use photocraft_doc::text::TextDirection as D; + [ + ("auto", tl!("Automatic paragraph direction"), D::Auto), + ("ltr", tl!("Left-to-right paragraph direction"), D::Ltr), + ("rtl", tl!("Right-to-left paragraph direction"), D::Rtl), + ] +} + +/// A pilcrow over an arrow pointing the way the paragraph runs; Auto points both ways. +fn direction_glyph(p: &egui::Painter, r: egui::Rect, d: photocraft_doc::text::TextDirection, c: Color32) { + use photocraft_doc::text::TextDirection as D; + let g = p.layout_no_wrap("¶".to_string(), egui::FontId::proportional(11.0), c); + p.galley(egui::pos2(r.center().x - g.size().x / 2.0, r.top() - 2.0), g, c); + let (y, s) = (r.bottom() - 1.5, Stroke::new(1.3, c)); + p.line_segment([egui::pos2(r.left(), y), egui::pos2(r.right(), y)], s); + let head = |x: f32, towards: f32| { + for dy in [-3.0, 3.0] { + p.line_segment([egui::pos2(x, y), egui::pos2(x - towards * 3.0, y + dy)], s); + } + }; + if d != D::Rtl { + head(r.right(), 1.0); + } + if d != D::Ltr { + head(r.left(), -1.0); + } +} + +/// The options bar's 24 px square button for one paragraph direction, styled like its icon buttons. +fn direction_button(ui: &mut egui::Ui, d: photocraft_doc::text::TextDirection, selected: bool, tip: &str) -> egui::Response { + let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); + let tint = crate::icons::button_chrome(ui, r, selected, resp.hovered()); + direction_glyph(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0)), d, tint); + let tip = tip.to_string(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &tip)); + resp.on_hover_text(tip) +} + /// A toggle button (faux styles, paragraph alignment) of width `w`, painted by `paint`. fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnce(&egui::Ui, egui::Rect, Color32)) -> bool { let t = crate::theme::Tokens::get(ui.ctx()); @@ -2122,7 +2583,17 @@ fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc:: /// characters (or its whole text) while typing, else every selected type layer. The new state /// is explicit, so it follows the button the user saw rather than the layer's first run. pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) { - let Some(mut p) = formatting_params(app) else { return }; + let Some(mut p) = formatting_params(app) else { + if let Some((tag, _, _)) = OPENTYPE_BUTTONS.iter().find(|(_, command, _)| *command == id) { + let props = match *tag { + "liga" => json!({"ligatures": !shown_on}), + "dlig" => json!({"discretionaryLigatures": !shown_on}), + _ => json!({"features": { (*tag): !shown_on }}), + }; + apply_defaults(app, &props); + } + return; + }; p["on"] = json!(!shown_on); let _ = app.run(id, p); } @@ -2189,28 +2660,22 @@ fn cell_width(avail: f32, n: usize) -> f32 { /// Properties panel sections for a type layer: Character, Paragraph and Type Options (#155). pub fn type_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - type_sections(app, ui, true, true); + type_sections(app, ui, true, true, false); type_options(app, ui); } /// Window › Character / Paragraph (#150): the same controls as Properties, in their own dock -/// group. Without a type layer (or a type edit) there is nothing to show. +/// group. Without a type target they edit the defaults for the next Type-tool layer. pub fn character_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool) { - if styles_at(app).is_none() { - let t = crate::theme::Tokens::get(ui.ctx()); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Select a type layer to edit its character and paragraph settings.")).color(t.text_dim)); - return; - } - type_sections(app, ui, !paragraph, paragraph); + type_sections(app, ui, !paragraph, paragraph, true); } /// The Character and/or Paragraph sections, with the shared collapsible headers and fields that /// fill the panel width (#155). -fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool) { +fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, paragraph: bool, show_defaults: bool) { use crate::props_layout::{COL_GAP, LABEL_GAP, LABEL_W, field_width, section}; use crate::theme::ROW_GAP; - let Some((c, para)) = styles_at(app) else { return }; + let Some((c, para)) = styles_at(app).or_else(|| show_defaults.then(|| default_styles(app))) else { return }; // Preferences ▸ Units & Rulers ▸ Type, as in the options bar: the model keeps points. let (u, unit) = type_unit(app); let t = crate::theme::Tokens::get(ui.ctx()); @@ -2229,7 +2694,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa let mut ready = true; row(ui, &mut |ui| { ready = fonts_ready(ui, full); - if ready && font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview) { + if ready && font_picker(ui, &mut fam, full, app.session.prefs().type_.font_preview, &app.ui.recent_fonts) { + app.note_recent_font(&fam); app.ui.tool_options.type_font = fam.clone(); apply(app, ui.ctx(), json!({"font": fam})); } @@ -2406,6 +2872,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa } }); ui.add_space(ROW_GAP); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (key, tip, d) in direction_items() { + let clicked = toggle_cell(ui, cw, para.direction == d, tip, |ui, r, col| { + let g = egui::Rect::from_center_size(r.center(), egui::vec2(14.0, 16.0)); + direction_glyph(ui.painter(), g, d, col); + }); + if clicked { + apply(app, ui.ctx(), json!({"direction": key})); + } + } + }); + ui.add_space(ROW_GAP); row(ui, &mut |ui| { if let Some(v) = num_field(ui, "→|", "Indent left margin", para.start_indent_pt * u, -1296.0 * u..=1296.0 * u, unit, w) { apply(app, ui.ctx(), json!({"startIndent": v / u})); @@ -2539,6 +3018,41 @@ mod tests { app } + /// Preferences ▸ Type ▸ Recent Fonts caps the recorded list; a re-pick moves to the front and + /// 0 keeps no list. + #[test] + fn note_recent_font_caps_and_de_duplicates() { + let mut app = app(); + for f in ["A", "B", "C"] { + app.note_recent_font(f); + } + assert_eq!(app.ui.recent_fonts, ["C", "B", "A"]); + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap(); + app.note_recent_font("D"); + assert_eq!(app.ui.recent_fonts, ["D", "C"], "the cap trims the tail"); + app.note_recent_font("C"); + assert_eq!(app.ui.recent_fonts, ["C", "D"], "a re-pick moves to the front"); + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap(); + app.note_recent_font("E"); + assert!(app.ui.recent_fonts.is_empty(), "0 keeps no list"); + } + + /// A smaller Recent Fonts cap (or 0) applies to the existing list at once, without waiting + /// for the next pick (CodeRabbit review of #3559). + #[test] + fn lowering_the_recent_fonts_cap_applies_at_once() { + let mut app = app(); + for f in ["A", "B", "C"] { + app.note_recent_font(f); + } + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 2})).unwrap(); + app.sync_recent_fonts(); + assert_eq!(app.ui.recent_fonts, ["C", "B"], "the smaller cap trims the tail without a pick"); + app.run("prefs.set", json!({"path": "type.recentFonts", "value": 0})).unwrap(); + app.sync_recent_fonts(); + assert!(app.ui.recent_fonts.is_empty(), "0 clears the list"); + } + fn layer_text(app: &PhotocraftApp) -> String { let ed = app.ui.text_edit.as_ref().unwrap(); current_text(app, LayerId(ed.layer)).unwrap() @@ -2659,6 +3173,62 @@ mod tests { assert!(app.ui.text_edit.is_none()); } + /// TYPE-218-1: a Type tool click on the work path starts type on the path from the point + /// clicked, in one history step with the placeholder selected; a click off it is point text. + #[test] + fn click_on_a_path_starts_type_on_the_path() { + use photocraft_doc::text::TextShape; + let mut app = app(); + app.session + .edit("path", |doc, _| { + doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(20.0, 100.0), (380.0, 100.0)])])); + Ok(()) + }) + .unwrap(); + assert!(path_at(&app, 200.0, 101.0).is_some()); + assert!(path_at(&app, 200.0, 160.0).is_none(), "off the path"); + let before = app.session.active().unwrap().history.entries().len(); + assert!(!pointer_down(&mut app, 200.0, 101.0, egui::Modifiers::NONE)); + pointer_up(&mut app, [200.0, 101.0], [200.0, 101.0]); + let ed = app.ui.text_edit.clone().unwrap(); + assert_eq!((ed.anchor, ed.caret), (0, PLACEHOLDER.chars().count()), "the placeholder is selected"); + let shape = text_layer(&app.session.active().unwrap().doc, LayerId(ed.layer)).unwrap().shape.clone(); + assert!(matches!(shape, TextShape::Path { start, flip: false, .. } if (start - 180.0).abs() < 0.5), "{shape:?}"); + assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one step for the click"); + insert(&mut app, "On the line"); + assert_eq!(layer_text(&app), "On the line"); + commit(&mut app); + // With the vertical tool a click does not start path type. + app.ui.tool = crate::state::Tool::VerticalType; + assert!(path_at(&app, 200.0, 101.0).is_none()); + app.ui.tool = crate::state::Tool::Type; + pointer_up(&mut app, [200.0, 160.0], [200.0, 160.0]); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert_eq!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, TextShape::Point); + } + + /// A click in type on a path puts the caret at the glyph there along the curve, not where the + /// straight line would have it. + #[test] + fn caret_hits_type_on_a_path_along_the_curve() { + let mut app = app(); + app.session + .edit("path", |doc, _| { + doc.work_path = Some(photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(300.0, 10.0), (300.0, 195.0)])])); + Ok(()) + }) + .unwrap(); + pointer_up(&mut app, [300.0, 20.0], [300.0, 20.0]); + insert(&mut app, "ABCDEFGH"); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert!(matches!(text_layer(&app.session.active().unwrap().doc, id).unwrap().shape, photocraft_doc::text::TextShape::Path { .. })); + // The type runs down the path: near its start the caret is at the beginning, far down at the end. + assert_eq!(offset_at(&mut app, id, 300.0, 18.0), 0); + // (The straight line would put both of these at the start: they are left of its first glyph.) + let (mid, far) = (offset_at(&mut app, id, 296.0, 60.0), offset_at(&mut app, id, 296.0, 190.0)); + assert!(0 < mid && mid < far, "{mid} {far}"); + } + /// Preferences ▸ Units & Rulers ▸ Type switches the display unit of type metrics: points by /// default, pixels by the document's resolution, or millimetres. #[test] @@ -2734,9 +3304,9 @@ mod tests { let steps = app.session.active().unwrap().history.entries().len(); // Top-left corner: the box keeps its bottom-right corner. assert!(pointer_down(&mut app, 10.0, 10.0, egui::Modifiers::NONE)); - assert_eq!(app.ui.text_edit.as_ref().unwrap().resize, Some(0)); - pointer_move(&mut app, 20.0, 25.0); - pointer_move(&mut app, 30.0, 30.0); + assert_eq!(app.ui.text_edit.as_ref().unwrap().resize.map(|r| r.handle), Some(0)); + pointer_move(&mut app, 20.0, 25.0, egui::Modifiers::NONE); + pointer_move(&mut app, 30.0, 30.0, egui::Modifiers::NONE); pointer_up(&mut app, [10.0, 10.0], [30.0, 30.0]); assert_eq!(box_shape(&app, id), Some((0.0, 0.0, 80.0, 30.0))); let aff = layout(&mut app, id).unwrap().1; @@ -2746,9 +3316,17 @@ mod tests { assert!(app.ui.text_edit.is_some()); // Right edge: only the width changes, and never below the minimum. assert!(pointer_down(&mut app, 110.0, 45.0, egui::Modifiers::NONE)); - pointer_move(&mut app, 0.0, 99.0); + pointer_move(&mut app, 0.0, 99.0, egui::Modifiers::NONE); pointer_up(&mut app, [110.0, 45.0], [0.0, 99.0]); assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX, 30.0))); + // ⌥ mirrors the drag about the centre: the left edge follows the right one out. + assert!(pointer_down(&mut app, 34.0, 45.0, egui::Modifiers::ALT)); + pointer_move(&mut app, 70.0, 45.0, egui::Modifiers::ALT); + pointer_up(&mut app, [34.0, 45.0], [70.0, 45.0]); + assert_eq!(box_shape(&app, id), Some((0.0, 0.0, MIN_BOX + 72.0, 30.0))); + let aff = layout(&mut app, id).unwrap().1; + // Text space −36..40 (centre 2) through the translation: the doc centre stays at 32. + assert_eq!((aff.m[4], aff.m[5]), (-6.0, 30.0)); } #[test] @@ -2881,3 +3459,7 @@ mod tests { assert_eq!(app.ui.tool, crate::state::Tool::Type, "Layers-thumbnail edit stays on Type tool"); } } + +#[cfg(test)] +#[path = "type_commit_tests.rs"] +mod commit_tests; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 50fceababb..e25baa6382 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -20,6 +20,7 @@ fn focused_font_search_does_not_type_into_the_canvas_layer() { let id = app.run("type.create", json!({"text": "Sample", "size": 24})).unwrap()["layer"].as_u64().unwrap(); app.ui.text_edit = Some(crate::state::TextEdit { layer: id, + document: None, caret: 6, anchor: 6, session: "s".into(), @@ -73,6 +74,240 @@ fn numeric_type_fields_have_localized_accessible_names() { assert_eq!(*h.state(), 36.0); } +/// #5556: the dedicated panels remain usable before there is any type layer, including +/// before opening a document. Properties still belongs to the selected layer. +#[test] +fn type_panels_show_default_controls_without_a_type_target() { + for app in [PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()), new_app()] { + let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + ui.columns(2, |columns| { + super::character_panel(app, &mut columns[0], false); + super::character_panel(app, &mut columns[1], true); + }); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_label("Faux Bold"); + h.get_by_label("Fractions"); + h.get_by_label("Center text"); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Font size"); + } +} + +mod panel_defaults { + use super::*; + use crate::state::{Tool, ToolOptions}; + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::NodeT; + use photocraft_doc::text::{Caps, Kerning, Orientation, TextAlign, TextShape}; + + fn panels(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + // Wait for the font scan: while it runs the panels repaint every 100 ms and `Harness::run` gives up. + let _ = photocraft_text::shared(); + let mut h = Harness::builder().with_size(vec2(760.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.columns(2, |columns| { + super::super::character_panel(app, &mut columns[0], false); + super::super::character_panel(app, &mut columns[1], true); + }); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h + } + + fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) { + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(value.into())); + h.key_press(egui::Key::Enter); + h.run(); + } + + #[test] + fn panel_defaults_are_editable_and_reach_every_type_tool() { + let mut h = panels(new_app()); + let revision = h.state().session.active().unwrap().revision; + let layers = h.state().session.active().unwrap().doc.walk().len(); + number(&mut h, "Font size", "36"); + number(&mut h, "Tracking (1/1000 em)", "80"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "44"); + number(&mut h, "Indent left margin", "9"); + for label in ["Faux Bold", "Fractions", "Standard Ligatures", "Discretionary Ligatures", "Center text"] { + h.get_by_label(label).click(); + h.run(); + } + assert_eq!(h.get_by_label("Fractions").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.get_by_label("Standard Ligatures").accesskit_node().toggled(), Some(Toggled::False)); + assert_eq!(h.state().session.active().unwrap().revision, revision, "pre-styling does not create document history"); + assert_eq!(h.state().session.active().unwrap().doc.walk().len(), layers); + let options = h.state().ui.tool_options.clone(); + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + for paragraph in [false, true] { + let mut app = new_app(); + app.ui.tool_options = options.clone(); + app.ui.tool = tool; + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + let revision = app.session.active().unwrap().revision; + super::super::pointer_up(&mut app, [100.0, 100.0], if paragraph { [500.0, 400.0] } else { [100.0, 100.0] }); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + let t = text(&app, id); + let c = &t.char_runs()[0].style; + let p = &t.paragraph_runs()[0].style; + assert_eq!(c.size_pt, 36.0); + assert_eq!(c.tracking, 80.0); + assert_eq!(c.leading_pt, Some(44.0)); + assert!(c.faux_bold && c.discretionary_ligatures && !c.ligatures); + assert!(photocraft_engine::type_extra_cmds::feature_on(c, "frac")); + assert_eq!(c.color.to_rgba8(), [51, 102, 153, 255]); + assert_eq!(p.align, TextAlign::Center); + assert_eq!(p.start_indent_pt, 9.0); + assert_eq!(t.orientation, if tool.is_vertical_type() { Orientation::Vertical } else { Orientation::Horizontal }); + assert_eq!(matches!(t.shape, TextShape::Box { .. }), paragraph); + assert_eq!(app.ui.text_edit.as_ref().unwrap().mask, tool.is_type_mask()); + assert!(app.session.active().unwrap().revision > revision); + super::super::cancel(&mut app); + assert!(app.session.active().unwrap().doc.layer(id).is_none(), "creating and formatting is one undoable edit"); + assert_eq!(app.ui.tool_options, options, "cancelling text does not discard the chosen defaults"); + } + } + } + + #[test] + fn extended_defaults_merge_serialize_and_reset_without_replacing_legacy_options() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + super::super::apply( + &mut app, + &ctx, + json!({ + "fontStyle": "Bold", "size": 32, "kerning": 25, "caps": "smallCaps", + "baselineShift": 3, "horizontalScale": 90, "verticalScale": 110, + "underline": true, "strikethrough": true, "fauxItalic": true, + "firstLineIndent": 5, "endIndent": 7, "spaceBefore": 11, "spaceAfter": 13, + "features": {"swsh": 1}, "variations": {"wght": 650, "wdth": 90} + }), + ); + super::super::apply(&mut app, &ctx, json!({"features": {"frac": 1}, "variations": {"slnt": -8}})); + let (c, p) = super::super::default_styles(&app); + assert_eq!((c.size_pt, c.kern, c.kerning, c.caps), (32.0, 25.0, Kerning::Off, Caps::SmallCaps)); + assert_eq!((c.baseline_shift_pt, c.horizontal_scale, c.vertical_scale), (3.0, 0.9, 1.1)); + assert!(c.underline && c.strikethrough && c.faux_italic); + assert_eq!((p.first_line_indent_pt, p.end_indent_pt, p.space_before_pt, p.space_after_pt), (5.0, 7.0, 11.0, 13.0)); + assert_eq!(c.features.iter().map(|f| f.tag.as_str()).collect::>(), ["frac", "swsh"]); + assert_eq!(c.variations.iter().map(|v| (v.axis.as_str(), v.value)).collect::>(), [("slnt", -8.0), ("wdth", 90.0), ("wght", 650.0)]); + // A named style replaces wght; it must not come back when a later layer is created. + super::super::apply(&mut app, &ctx, json!({"fontStyle": "Regular"})); + let (c, _) = super::super::default_styles(&app); + assert!(!c.variations.iter().any(|v| v.axis == "wght")); + assert_eq!(c.variations.len(), 2); + let saved = serde_json::to_value(&app.ui.tool_options).unwrap(); + let restored: ToolOptions = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(restored, app.ui.tool_options); + crate::tool_memory::persist(&mut app, &ctx); + let remembered = app.session.prefs().tool_options.clone(); + let mut restarted = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + restarted.session.prefs.edit(|p| p.tool_options = remembered); + crate::tool_memory::restore(&mut restarted); + assert_eq!(restarted.ui.tool_options, restored, "the real preference restore path retains panel defaults"); + let mut legacy = saved; + legacy.as_object_mut().unwrap().remove("type_style_defaults"); + let legacy: ToolOptions = serde_json::from_value(legacy).unwrap(); + assert_eq!((legacy.type_font.as_str(), legacy.type_style.as_str(), legacy.type_size), ("Inter", "Regular", 32.0)); + assert!(legacy.type_style_defaults.is_empty()); + for tool in [Tool::Type, Tool::VerticalType, Tool::HorizontalTypeMask, Tool::VerticalTypeMask] { + let mut reset = restored.clone(); + crate::tool_reset::reset_options(&mut reset, tool); + assert!(reset.type_style_defaults.is_empty()); + assert_eq!(reset.type_size, ToolOptions::default().type_size); + } + } + + #[test] + fn selected_text_edits_leave_panel_defaults_and_other_characters_untouched() { + let mut app = new_app(); + let ctx = egui::Context::default(); + super::super::apply(&mut app, &ctx, json!({"tracking": 80, "fauxBold": true})); + let defaults = app.ui.tool_options.type_style_defaults.clone(); + let id = LayerId(app.run("type.create", json!({"text": "AéBC", "size": 24})).unwrap()["layer"].as_u64().unwrap()); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + document: None, + caret: 2, + anchor: 1, + session: "panel-default-selection".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + super::super::apply(&mut app, &ctx, json!({"tracking": 15})); + super::super::toggle_opentype(&mut app, "type.openType.fractions", false); + let runs = text(&app, id).char_runs(); + assert_eq!(runs.iter().map(|r| (r.len, r.style.tracking)).collect::>(), [(1, 0.0), (2, 15.0), (2, 0.0)]); + assert!(photocraft_engine::type_extra_cmds::feature_on(&runs[1].style, "frac")); + assert!(!photocraft_engine::type_extra_cmds::feature_on(&runs[0].style, "frac")); + assert_eq!(app.ui.tool_options.type_style_defaults, defaults); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&app, id).char_runs().len(), 1, "selection edits keep their typing-session coalescing"); + assert_eq!(text(&app, id).char_runs()[0].style.tracking, 0.0); + } + + #[test] + fn default_text_color_uses_foreground_and_cancel_preserves_it() { + let mut app = new_app(); + let before = app.session.tools.foreground; + let revision = app.session.active().unwrap().revision; + let dialog = super::super::open_color_picker(&mut app, [0.0; 3]); + app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::cancel(&mut app, dialog).unwrap(); + assert_eq!(app.session.tools.foreground, before); + let dialog = super::super::open_color_picker(&mut app, [0.0; 3]); + app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#336699")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert_eq!(super::super::default_styles(&app).0.color.to_rgba8(), [51, 102, 153, 255]); + assert_eq!(app.session.active().unwrap().revision, revision); + super::super::pointer_up(&mut app, [100.0, 100.0], [100.0, 100.0]); + let id = LayerId(app.ui.text_edit.as_ref().unwrap().layer); + assert_eq!(text(&app, id).char_runs()[0].style.color.to_rgba8(), [51, 102, 153, 255]); + } + + #[test] + fn unknown_saved_defaults_cannot_become_creation_parameters() { + let mut app = new_app(); + app.ui.tool_options.type_style_defaults = + json!({"tracking": 20, "text": "Injected", "box": [0, 0, 2, 2], "coalesce": "unrelated"}).as_object().unwrap().clone(); + let p = super::super::default_style_params(&app); + assert_eq!(p["tracking"], 20); + assert!(p.get("text").is_none() && p.get("box").is_none() && p.get("coalesce").is_none()); + app.ui.tool_options.type_style_defaults = json!({ + "tracking": 1e300, "leading": 1e300, "spaceAfter": 1e300, + "variations": {"wght": 1e300}, "features": {"frac": {"bad": 1}}, "fauxBold": true + }) + .as_object() + .unwrap() + .clone(); + let p = super::super::default_style_params(&app); + for key in ["tracking", "leading", "spaceAfter", "variations", "features"] { + assert!(p.get(key).is_none(), "hostile {key} is ignored"); + } + let (c, para) = super::super::default_styles(&app); + assert!(c.faux_bold && c.leading_pt.is_none() && c.tracking == 0.0 && para.space_after_pt == 0.0); + } +} + fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { make_harness(ppp, app, false) } @@ -400,6 +635,7 @@ fn size_applies_at_layer_and_selection_scope() { // Selection scope: "world" only. app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 11, anchor: 6, session: "s".into(), @@ -453,6 +689,110 @@ fn size_drag_is_live_and_one_history_step_per_drag() { assert_eq!(size_at(h.state(), id, 0), 20.0); } +fn options_harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(900.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.horizontal(|ui| super::options_bar(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(3); + h +} + +/// Drags the options bar's "T" icon `dx` points to the right with `mods` held. +fn scrub_size_icon(h: &mut Harness<'static, PhotocraftApp>, dx: f32, mods: Modifiers) { + let icon = h.get_by_label("Font size").rect().center(); + h.hover_at(icon); + h.run_steps(1); + press(h, icon, true); + for k in 1..=5 { + h.event(egui::Event::ModifiersChanged(mods)); + h.hover_at(icon + vec2(dx * k as f32 / 5.0, 0.0)); + h.run_steps(1); + } + press(h, icon + vec2(dx, 0.0), false); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +fn history_len(h: &Harness<'static, PhotocraftApp>) -> usize { + h.state().session.active().unwrap().history.entries().len() +} + +/// Photoshop's options bar "T" icon is a scrubby slider for the size beside it, like the +/// Character panel's icons (#6437): a point per point dragged, the whole drag one undo step. +#[test] +fn options_bar_t_icon_scrubs_the_size_in_one_history_step() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = options_harness(app); + let steps = history_len(&h); + scrub_size_icon(&mut h, 30.0, Modifiers::NONE); + assert_eq!(size_at(h.state(), id, 0), 50.0, "30 points to the right: 20 pt → 50 pt"); + assert_eq!(history_len(&h), steps + 1, "one history step for the whole drag"); + assert!(h.state_mut().session.undo()); + assert_eq!(size_at(h.state(), id, 0), 20.0); +} + +/// While editing with characters selected, scrubbing the "T" icon sizes only the selection. +#[test] +fn options_bar_t_icon_scrubs_the_selected_characters_only() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello world", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + app.ui.text_edit = Some(crate::state::TextEdit { + layer: id.0, + caret: 11, + anchor: 6, + session: "s".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + document: None, + }); + let mut h = options_harness(app); + scrub_size_icon(&mut h, -8.0, Modifiers::NONE); + let sizes = [0, 5, 6, 10].map(|ci| size_at(h.state(), id, ci)); + assert_eq!(sizes, [20.0, 20.0, 12.0, 12.0], "\"world\" goes 20 pt → 12 pt, \"Hello \" stays"); +} + +/// Scrubbing the "T" icon stops at the size field's limits: 1 pt and 1296 pt. +#[test] +fn options_bar_t_icon_scrub_stops_at_the_size_limits() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let mut h = options_harness(app); + scrub_size_icon(&mut h, -60.0, Modifiers::NONE); + assert_eq!(size_at(h.state(), id, 0), 1.0, "60 points left of 20 pt stops at the minimum"); + scrub_size_icon(&mut h, 200.0, Modifiers::SHIFT); + assert_eq!(size_at(h.state(), id, 0), 1296.0, "Shift (10 pt per point) past the top stops at the maximum"); +} + +/// Rests the pointer on the widget named `label` past the tooltip delay; true when its tooltip +/// (a second node with the same name) shows. +fn tooltip_shows(h: &mut Harness<'static, PhotocraftApp>, label: &str) -> bool { + let at = h.get_by_label(label).rect().center(); + h.hover_at(at); + h.run_steps(8); + h.query_all_by_label(label).count() == 2 +} + +/// The scrubby "T" icon keeps its "Font size" tooltip: the size field's drag area, laid over the +/// icon after it, must not take its hover. +#[test] +fn options_bar_t_icon_shows_its_tooltip() { + let mut app = new_app(); + app.run("type.create", json!({"text": "Hello", "size": 20, "x": 300, "y": 420})).unwrap(); + let mut h = options_harness(app); + assert!(tooltip_shows(&mut h, "Set the text color"), "the probe sees an ordinary tooltip"); + h.hover_at(egui::pos2(890.0, 55.0)); + h.run_steps(4); + assert!(tooltip_shows(&mut h, "Font size"), "hovering the \"T\" icon shows \"Font size\""); +} + /// A point inside glyph `i` of vertical type: `f` of the way down its advance, right of the /// column's centre line. fn vglyph(h: &mut Harness<'static, PhotocraftApp>, id: LayerId, i: usize, f: f32) -> Pos2 { @@ -547,6 +887,7 @@ fn foreground_colour_recolours_only_selected_type() { assert_eq!(rgb_at(&app, id, 0), [0, 0, 0, 255], "not editing: unchanged"); let edit = |caret, anchor| crate::state::TextEdit { layer: id.0, + document: None, caret, anchor, session: "s".into(), @@ -636,6 +977,7 @@ fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() { app.run("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 11, anchor: 6, session: "text-color-dialog-test".into(), @@ -693,6 +1035,7 @@ fn empty_ime_events_never_delete_the_selection() { let id = LayerId(app.run("type.create", json!({"text": "Hello", "size": 40, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 5, anchor: 0, session: "s".into(), @@ -777,6 +1120,84 @@ fn assert_affine(a: Affine, b: Affine) { } } +/// #5956: while typing, a plain drag from just outside the text moves it (Photoshop's Move cursor +/// there), and typing carries on; a press further out commits the text, as before. +#[test] +fn a_drag_just_outside_the_typed_text_moves_it() { + let (app, id) = transform_app(false, false, Affine::translate(500.0, 350.0)); + let mut h = harness(1.0, app); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let zoom = f64::from(h.state().point_zoom()); + let mid = (f.quad[1][1] + f.quad[2][1]) / 2.0; + let near = frame_point(h.state(), [f.quad[1][0] + 20.0 / zoom, mid]); + let (ctx, view) = (h.ctx.clone(), xf(h.state())); + let cursor = crate::type_transform::cursor(h.state_mut(), &ctx, &view, near, Modifiers::NONE); + assert_eq!(cursor, Some(egui::CursorIcon::Move), "the Move cursor just outside the text"); + let before = text(h.state(), id); + let count = h.state().session.active().unwrap().history.entries().len(); + transform_drag(&mut h, near, near + egui::vec2(60.0, 30.0), Modifiers::NONE, false); + let after = text(h.state(), id); + assert_affine(after.transform, Affine::translate(60.0 / zoom, 30.0 / zoom).mul(&before.transform)); + assert_eq!(after.text, before.text); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| e.layer), Some(id.0), "still typing"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), count + 1); + // Further out: the press commits the text. + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let far = frame_point(h.state(), [f.quad[1][0] + 100.0 / zoom, (f.quad[1][1] + f.quad[2][1]) / 2.0]); + let view = xf(h.state()); + assert_eq!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, far, Modifiers::NONE), None); + click(&mut h, far); + assert!(h.state().ui.text_edit.is_none(), "committed"); + assert_affine(text(h.state(), id).transform, after.transform); +} + +/// #5956 review: the reach is screen points as the view draws them, so a pixel-aspect stretch +/// doesn't widen it, and a press on another type layer near the text is still the Type tool's. +#[test] +fn the_move_reach_is_on_screen_and_leaves_other_type_alone() { + let (mut app, id) = transform_app(false, false, Affine::translate(500.0, 350.0)); + app.ui.view.pixel_aspect_correction = true; + app.ui.view.pixel_aspect = "anamorphic2To1".into(); + let mut h = harness(1.0, app); + h.run_steps(1); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let mid = (f.quad[1][1] + f.quad[2][1]) / 2.0; + let edge = frame_point(h.state(), [f.quad[1][0], mid]); + let (ctx, view) = (h.ctx.clone(), xf(h.state())); + assert_eq!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, edge + egui::vec2(30.0, 0.0), Modifiers::NONE), Some(egui::CursorIcon::Move)); + // 50 points out is 25 document points across a 2:1 pixel: out of reach. + assert_eq!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, edge + egui::vec2(50.0, 0.0), Modifiers::NONE), None); + let before = text(h.state(), id); + transform_drag(&mut h, edge + egui::vec2(50.0, 0.0), edge + egui::vec2(110.0, 30.0), Modifiers::NONE, false); + assert_affine(text(h.state(), id).transform, before.transform); +} + +/// #5956 review: a press on another type layer within reach of the text being typed is the Type +/// tool's (it commits and moves to that text), not a move of the edited text. +#[test] +fn a_press_on_other_type_near_the_text_does_not_move_it() { + let mut app = new_app(); + let id = LayerId(app.run("type.create", json!({"text": "Hi", "size": 32, "x": 300, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + let right = app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds().x1; + let other = LayerId(app.run("type.create", json!({"text": "Other", "size": 32, "x": right + 6, "y": 420})).unwrap()["layer"].as_u64().unwrap()); + app.run("layer.select", json!({"layer": id.0})).unwrap(); + super::edit_active(&mut app).unwrap(); + let mut h = harness(1.0, app); + h.run_steps(1); + let f = crate::type_transform::frame(h.state_mut()).unwrap(); + let ob = h.state().session.active().unwrap().doc.layer(other).unwrap().surface().unwrap().content_bounds(); + let mid = f64::from(ob.y0 + ob.y1) / 2.0; + let on_other = frame_point(h.state(), [f64::from(ob.x0) + 4.0, mid]); + let edge = frame_point(h.state(), [f.quad[1][0], mid]); + assert!(on_other.x - edge.x > 0.0 && on_other.x - edge.x < 40.0, "the other text is within reach: {} pt", on_other.x - edge.x); + let (ctx, view) = (h.ctx.clone(), xf(h.state())); + assert_ne!(crate::type_transform::cursor(h.state_mut(), &ctx, &view, on_other, Modifiers::NONE), Some(egui::CursorIcon::Move)); + let before = text(h.state(), id); + transform_drag(&mut h, on_other, on_other + egui::vec2(60.0, 30.0), Modifiers::NONE, false); + assert_affine(text(h.state(), id).transform, before.transform); + assert_ne!(h.state().ui.text_edit.as_ref().map(|e| e.layer), Some(id.0), "the edit moved on"); +} + /// #1091: modifier changes alone draw/hide the oriented frame without touching editing/history. #[test] fn temporary_type_transform_visibility_and_cursors() { @@ -1430,6 +1851,7 @@ fn the_font_menu_lists_served_families() { mod multi_layer_formatting { use super::*; use egui::accesskit::Role; + use egui_kittest::kittest::NodeT; use photocraft_doc::text::{AntiAlias, CharStyle, Orientation, TextAlign}; fn selected(app: &mut PhotocraftApp, ids: &[LayerId]) { @@ -1497,6 +1919,7 @@ mod multi_layer_formatting { fn editing(app: &mut PhotocraftApp, id: LayerId) { app.ui.text_edit = Some(crate::state::TextEdit { layer: id.0, + document: None, caret: 3, anchor: 1, session: "batch-type-edit".into(), @@ -1508,6 +1931,62 @@ mod multi_layer_formatting { }); } + /// The Paragraph section's direction buttons set the selected layers' paragraph direction in + /// one undo step, show the current one, and leave unselected layers and the tool's default for + /// new type alone (TYPE-218-2). + #[test] + fn paragraph_direction_buttons_set_and_show_the_direction() { + use egui::accesskit::Toggled; + use egui_kittest::kittest::NodeT; + use photocraft_doc::text::TextDirection; + let (app, ids) = fixture(); + let dir = |app: &PhotocraftApp, id: LayerId| text(app, id).paragraph_runs()[0].style.direction; + let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled(); + let mut h = controls(app, true); + assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::True), "new type follows its text"); + assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::False)); + let steps = h.state().session.active().unwrap().history.entries().len(); + h.get_by_label("Right-to-left paragraph direction").click(); + h.run(); + for id in &ids[..2] { + assert_eq!(dir(h.state(), *id), TextDirection::Rtl, "selected layers get the direction"); + } + assert_eq!(dir(h.state(), ids[2]), TextDirection::Auto, "an unselected layer is untouched"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 1, "one undo step"); + assert_eq!(h.state().ui.tool_options.type_direction, "auto", "the panel edits layers, not new type"); + h.run(); + assert_eq!(shown(&h, "Right-to-left paragraph direction"), Some(Toggled::True), "the button shows it"); + assert_eq!(shown(&h, "Automatic paragraph direction"), Some(Toggled::False)); + h.get_by_label("Left-to-right paragraph direction").click(); + h.run(); + assert_eq!(dir(h.state(), ids[0]), TextDirection::Ltr); + assert!(h.state_mut().session.undo()); + assert!(h.state_mut().session.undo()); + for id in &ids[..2] { + assert_eq!(dir(h.state(), *id), TextDirection::Auto, "undo restores it"); + } + } + + /// The options bar's direction buttons set the selected layers' direction and the one new + /// type starts with, as its alignment buttons do (TYPE-218-2). + #[test] + fn options_bar_direction_sets_selected_layers_and_new_type() { + use photocraft_doc::text::TextDirection; + let (app, ids) = fixture(); + let mut h = controls(app, false); + h.get_by_label("Right-to-left paragraph direction").click(); + h.run(); + assert_eq!(h.state().ui.tool_options.type_direction, "rtl"); + for id in &ids[..2] { + assert_eq!(text(h.state(), *id).paragraph_runs()[0].style.direction, TextDirection::Rtl); + } + assert_eq!(text(h.state(), ids[2]).paragraph_runs()[0].style.direction, TextDirection::Auto); + super::super::pointer_up(h.state_mut(), [40.0, 60.0], [40.0, 60.0]); + let id = h.state().session.active().unwrap().active_layer.unwrap(); + assert!(!ids.contains(&id), "a new type layer"); + assert_eq!(text(h.state(), id).paragraph_runs()[0].style.direction, TextDirection::Rtl, "new type starts right to left"); + } + #[test] fn options_font_family_preserves_peer_faces_and_batches_layer_flags() { let (app, ids) = fixture(); @@ -1629,9 +2108,13 @@ mod multi_layer_formatting { #[test] fn character_and_paragraph_fields_follow_the_type_units_preference() { use egui_kittest::kittest::NodeT; - let (mut app, ids) = fixture(); + // The resolution is set before the type exists: Image Size keeps existing type's pixel + // size (#7125), so a layer made earlier would be 6 pt here. + let mut app = new_app(); app.run("prefs.set", json!({"path": "unitsAndRulers.typeUnits", "value": "pixels"})).unwrap(); app.run("image.imageSize", json!({"resolution": 144, "resample": "none"})).unwrap(); + let ids = + [LayerId(app.run("type.create", json!({"text": "AéBCD", "font": "Inter", "size": 12, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap())]; selected(&mut app, &[ids[0]]); let mut h = controls(app, true); let shown = h.get_by_role_and_label(Role::SpinButton, "Font size").accesskit_node().numeric_value(); @@ -1846,6 +2329,28 @@ mod multi_layer_formatting { assert_eq!(style(h.state(), id, 0), expected); } } + + /// #5911: the options bar's Create warped text button opens Warp Text for the selected type + /// layers, as Type › Warp Text… does; with no type layer to warp it is greyed. + #[test] + fn options_bar_warp_button_warps_the_selected_type_layers() { + let (app, ids) = fixture(); + let mut h = controls(app, false); + h.get_by_role_and_label(Role::Button, "Create warped text").click(); + h.run(); + let dialog = h.state().ui.dialogs.last().unwrap(); + assert_eq!(dialog.fields.get("__command").and_then(|c| c.as_str()), Some("type.warpText")); + let dialog = dialog.id; + h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("style".into(), json!("arc")); + crate::dialogs::confirm(h.state_mut(), dialog).unwrap(); + for id in &ids[..2] { + assert!(text(h.state(), *id).warp.is_some(), "a selected layer is warped"); + } + assert!(text(h.state(), ids[2]).warp.is_none(), "an unselected layer is left alone"); + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.run(); + assert!(h.get_by_role_and_label(Role::Button, "Create warped text").accesskit_node().is_disabled()); + } } /// #1132: with the Move tool, double-clicking type on the canvas edits it (Photoshop CC 2019+): @@ -2024,6 +2529,77 @@ fn multi_click_selects_word_line_paragraph_and_all_in_point_text() { assert_eq!(selection(&h), (0, edited.chars().count()), "⌘A selects all text"); } +/// #6819: all eight paragraph-box handles resize the box while editing, with View › Snap on (the +/// default), at 25–200 % zoom, 1× and 2× HiDPI, for a plain, a scaled and a rotated layer. A press +/// on a handle (anywhere in its grab area) never moves the caret, and the edited text's own glyph +/// bounds don't pull the press off the handle. +#[test] +fn every_paragraph_box_handle_resizes_the_box_with_snapping_on() { + let scaled = Affine { m: [1.5, 0.0, 0.0, 0.75, 300.0, 300.0] }; + let rotated = Affine { m: [1.2 * 0.94, 1.2 * 0.342, -1.2 * 0.342, 1.2 * 0.94, 300.0, 300.0] }; + for ppp in [1.0, 2.0] { + for zoom in [0.25f32, 0.5, 1.0, 2.0] { + for (name, tf) in [("plain", None), ("scaled", Some(scaled)), ("rotated", Some(rotated))] { + let mut app = new_app(); + assert!(app.ui.extras.snap, "snapping is on by default"); + let size = 24.0 / zoom; + let p = json!({"text": "HOHOHO HOHO HOHOHO HOHO HOHOHO", "size": size, "x": 300, "y": 300, "box": [300.0, 300.0, size * 8.0, size * 4.0]}); + let id = LayerId(app.run("type.create", p).unwrap()["layer"].as_u64().unwrap()); + if let Some(a) = tf { + app.run("type.edit", json!({"layer": id.0, "transform": a.m})).unwrap(); + } + let mut h = harness(ppp, app); + let (bx, by, w, bh) = super::box_shape(h.state(), id).unwrap(); + let aff = layout(h.state_mut(), id).unwrap().1; + let m = aff.apply(Point::new(f64::from(bx + w / 2.0), f64::from(by + bh / 2.0))); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (zoom, [m.x as f32, m.y as f32], false); + h.run_steps(2); + let ctx = format!("ppp {ppp} zoom {zoom} {name}"); + let p = glyph(&mut h, id, 2, 0.2); + click(&mut h, p); + assert_eq!(selection(&h), (2, 2), "{ctx}: editing"); + for i in 0..8u8 { + // Press at the handle and inside its grab area, towards the text. + for off in [0.0f32, 4.0] { + let (bx, by, w, bh) = super::box_shape(h.state(), id).unwrap(); + let (l, r, t, b) = super::handle_sides(i); + let hx = if l { + bx + } else if r { + bx + w + } else { + bx + w / 2.0 + }; + let hy = if t { + by + } else if b { + by + bh + } else { + by + bh / 2.0 + }; + let dir = vec2(f32::from(u8::from(r)) - f32::from(u8::from(l)), f32::from(u8::from(b)) - f32::from(u8::from(t))); + let at = screen(&mut h, id, hx, hy); + let out = (screen(&mut h, id, hx + dir.x * 10.0, hy + dir.y * 10.0) - at).normalized(); + let a = at - out * off; + drag(&mut h, a, a + out * 30.0); + let ctx = format!("{ctx} handle {i} offset {off}"); + assert_eq!(selection(&h), (2, 2), "{ctx}: the caret stays put"); + let (_, _, w2, h2) = super::box_shape(h.state(), id).unwrap(); + // 30 points outward (a corner's diagonal: about 21 per side) is that over + // zoom × layer scale in text units; snapping may shift the release by up to + // its 8 point reach. + let min = 6.0 / (zoom / h.state().canvas_ppp()) / 1.5; + let grew = |was: f32, now: f32, moved: bool| if moved { now - was >= min } else { (now - was).abs() < 1e-3 }; + assert!(grew(w, w2, l || r), "{ctx}: width {w} -> {w2}"); + assert!(grew(bh, h2, t || b), "{ctx}: height {bh} -> {h2}"); + } + } + } + } + } +} + /// Paragraph text: three clicks select only the visual (soft-wrapped) line, four the whole /// paragraph; vertical type selects the column the same way. #[test] @@ -2110,6 +2686,7 @@ fn multi_click_without_a_session_does_nothing() { assert!(app.ui.text_edit.is_none() && app.type_clicks.is_none()); app.ui.text_edit = Some(crate::state::TextEdit { layer: 999_999, + document: None, caret: 5, anchor: 5, session: "stale".into(), @@ -2211,3 +2788,85 @@ fn justify_buttons_are_greyed_on_point_text() { h.run_steps(4); assert!(h.get_by_label("Justify all").accesskit_node().is_disabled(), "one of the formatted layers is point text"); } + +/// The paragraph box's document rect: its text-space origin through the layer transform, then +/// width and height. Only what the symmetric-resize tests need (identity linear part). +fn box_doc_rect(app: &PhotocraftApp, id: LayerId) -> [f64; 4] { + let t = text(app, id); + let (x, y, w, h) = match t.shape { + photocraft_doc::text::TextShape::Box { x, y, width, height } => (f64::from(x), f64::from(y), f64::from(width), f64::from(height)), + ref s => panic!("not a box: {s:?}"), + }; + let p = t.transform.apply(Point::new(x, y)); + [p.x, p.y, w, h] +} + +/// ⌥ on an edge drag of a paragraph box mirrors the opposite edge about the box's centre, so +/// the block grows symmetrically; without ⌥ the opposite edge stays put. +#[test] +fn alt_dragging_a_box_edge_mirrors_the_opposite_edge() { + use crate::canvas::{ToolEvent, tool_event}; + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + // The box is at (300, 300), 240×180: its bottom-edge handle is the midpoint (420, 480). + tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 530.0 }, Modifiers::NONE); + assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 230.0], "plain drag: the top edge stays"); + tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 530.0, pressure: 1.0 }, Modifiers::ALT); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 580.0, pressure: 1.0 }, Modifiers::ALT); + tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 580.0 }, Modifiers::ALT); + // The centre (300 + 115 = 415) held: the bottom followed the pointer (580), the top + // mirrored up to 250. + assert_eq!(box_doc_rect(&app, id), [300.0, 250.0, 240.0, 330.0]); +} + +/// ⌥ pressed or released mid-drag applies as if it had been held from the start: the mirror +/// turns about the centre the box had at the press, not about wherever the box drifted. +#[test] +fn alt_pressed_mid_drag_mirrors_about_the_press_centre() { + use crate::canvas::{ToolEvent, tool_event}; + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + // Bottom edge (420, 480) dragged down 80 px without ⌥: only the bottom moves. + tool_event(&mut app, ToolEvent::Down { x: 420.0, y: 480.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 520.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 560.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 260.0]); + // ⌥ pressed mid-drag: the bottom follows the pointer, the top jumps to the mirror about + // the press centre (390): 2*390 − 600 = 180 — not about the drifted one (430). + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::ALT); + assert_eq!(box_doc_rect(&app, id), [300.0, 180.0, 240.0, 420.0]); + // ⌥ released again mid-drag: the top snaps back to where a plain drag leaves it. + tool_event(&mut app, ToolEvent::Move { x: 420.0, y: 600.0, pressure: 1.0 }, Modifiers::NONE); + assert_eq!(box_doc_rect(&app, id), [300.0, 300.0, 240.0, 300.0]); + tool_event(&mut app, ToolEvent::Up { x: 420.0, y: 600.0 }, Modifiers::NONE); +} + +/// ⌥ mirrors a side-edge drag the same way, and a corner drag grows both axes about the +/// centre; clamped, the ⌥ box stops at MIN_BOX about its centre instead of collapsing. +#[test] +fn alt_mirrors_side_and_corner_drags_and_clamps_at_the_minimum() { + use crate::canvas::{ToolEvent, tool_event}; + let drag = |app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], mods: Modifiers| { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, mods); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, mods); + }; + // Right-edge handle: (540, 390) for the box at (300, 300), 240×180. + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + drag(&mut app, [540.0, 390.0], [590.0, 390.0], Modifiers::ALT); + // cx = 420 held: the right edge followed to 590, the left mirrored from 300 to 250. + assert_eq!(box_doc_rect(&app, id), [250.0, 300.0, 340.0, 180.0]); + // Clamped: dragging the right edge past the centre leaves the minimum box about it. + drag(&mut app, [590.0, 390.0], [400.0, 390.0], Modifiers::ALT); + let r = box_doc_rect(&app, id); + assert_eq!(r[2], 4.0, "MIN_BOX wide"); + assert!(((r[0] + r[2] / 2.0) - 420.0).abs() < 1e-9, "still centred on 420"); + // Corner handle (bottom-right of the fresh box): ⌥ grows both axes about the centre. + let (mut app, id) = transform_app(true, false, Affine::translate(300.0, 300.0)); + app.ui.extras.snap = false; + drag(&mut app, [540.0, 480.0], [590.0, 530.0], Modifiers::ALT); + assert_eq!(box_doc_rect(&app, id), [250.0, 250.0, 340.0, 280.0]); +} diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index 0c1b4837ed..8144a7991c 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -17,6 +17,11 @@ use crate::transform_tool::{Gesture, Hit}; const PREVIEW_BASE: u64 = 1 << 37; const ROTATE_PX: f64 = 28.0; +/// How far outside the text being typed a plain press moves it, as the Move tool (#5956), in +/// screen points. Photoshop 2025, measured at 100 %: a drag from 35 pt off the glyphs moved the +/// text without ending typing, one from 48 pt committed it instead. Kept under 40 pt, so a press +/// 40 pt outside a paragraph box still commits the text (#7606). +const MOVE_REACH_PT: f64 = 38.0; /// Cached rendered preview and bounded damage history. Unchanged pointer positions render nothing. pub(crate) struct Preview { @@ -140,6 +145,65 @@ fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { }) } +/// Screen points from `p` to the frame's outline, as the view draws it (zoom, rotation and pixel +/// aspect). +fn border_points(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> f64 { + let xf = ViewXform::active(app); + let zoom = f64::from(app.point_zoom().max(0.01)); + let on_screen = |q: [f64; 2]| { + let d = [q[0] - p[0], q[1] - p[1]]; + match xf { + Some(xf) => { + let v = xf.map_vec(egui::vec2(d[0] as f32, d[1] as f32)); + [f64::from(v.x), f64::from(v.y)] + } + None => [d[0] * zoom, d[1] * zoom], + } + }; + t.quad + .iter() + .zip(t.quad.iter().cycle().skip(1)) + .take(4) + .map(|(a, b)| { + let (a, b) = (on_screen(*a), on_screen(*b)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx * dx + dy * dy; + let f = if len > 0.0 { (-a[0] * dx - a[1] * dy) / len } else { 0.0 }.clamp(0.0, 1.0); + (a[0] + f * dx).hypot(a[1] + f * dy) + }) + .fold(f64::INFINITY, f64::min) +} + +/// The text's frame when `p` is just outside it, where a plain press moves the text (#5956). Over +/// another type layer the press is the Type tool's, as before. +fn move_zone(app: &mut PhotocraftApp, p: [f64; 2]) -> Option { + let t = frame(app)?; + if crate::transform_tool::hit(&t, p, 0.0) != Hit::Outside || border_points(app, &t, p) > MOVE_REACH_PT { + return None; + } + // Inside a paragraph box, off the glyphs, a press places the caret, as in Photoshop (#7606). + if crate::type_tool::inside_box(app, LayerId(t.layer), p[0], p[1]) { + return None; + } + let other = crate::type_tool::hit_layer(app, p[0], p[1]).is_some_and(|id| id.0 != t.layer); + (!other).then_some(t) +} + +/// Start a temporary drag of `frame` from `p` on `h`. Returns false when it can't start. +fn start(app: &mut PhotocraftApp, mut frame: TransformSession, h: Hit, p: [f64; 2]) -> bool { + let Some((_, original, _)) = crate::type_tool::layout(app, LayerId(frame.layer)) else { return false }; + let Some(st) = app.session.active() else { return false }; + let (document, revision) = (st.doc.id.0, st.revision); + frame.session = app.ui.alloc_id(); + let gesture = Gesture { hit: h, start: p, quad0: frame.quad, pivot0: frame.pivot }; + app.ui.type_transform = Some(TypeTransform { document, revision, original, frame, gesture }); + if let Some(ed) = app.ui.text_edit.as_mut() { + ed.dragging = false; + ed.resize = None; + } + true +} + fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option { let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)); (h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.point_zoom().max(0.01)))).then_some(h) @@ -242,20 +306,22 @@ pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) - if !p.iter().all(|v| v.is_finite()) { return true; } - let Some(mut frame) = frame(app) else { return true }; + let Some(frame) = frame(app) else { return true }; let Some(h) = hit(app, &frame, p) else { return true }; - let Some((_, original, _)) = crate::type_tool::layout(app, LayerId(frame.layer)) else { return true }; - let Some(st) = app.session.active() else { return true }; - let (document, revision) = (st.doc.id.0, st.revision); - frame.session = app.ui.alloc_id(); - let gesture = Gesture { hit: h, start: p, quad0: frame.quad, pivot0: frame.pivot }; - app.ui.type_transform = Some(TypeTransform { document, revision, original, frame, gesture }); - if let Some(ed) = app.ui.text_edit.as_mut() { - ed.dragging = false; - ed.resize = None; - } + start(app, frame, h, p); true } + // Just outside the text, a plain press moves it as the Move tool would, without ending + // typing, as in Photoshop (#5956). Further out the press commits (`type_tool`), and a + // paragraph box's handles still resize it. + ToolEvent::Down { x, y, .. } if x.is_finite() && y.is_finite() => { + let id = app.ui.text_edit.as_ref().map(|e| LayerId(e.layer)); + if id.is_some_and(|id| crate::type_tool::box_handle_at(app, id, x, y).is_some()) { + return false; + } + let Some(frame) = move_zone(app, [x, y]) else { return false }; + start(app, frame, Hit::Inside, [x, y]) + } ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } if active(app) => { update(app, [x, y], mods); if matches!(ev, ToolEvent::Up { .. }) { @@ -380,26 +446,21 @@ pub(crate) fn cursor(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXfor if !visible(app, mods) { let id = LayerId(app.ui.text_edit.as_ref()?.layer); let q = xf.to_doc(p); - let handle = crate::type_tool::box_handle_at(app, id, q[0], q[1])?; - let t = frame(app)?; - let direction = if handle < 4 { - let a = *t.quad.get(usize::from(handle))?; - let b = *t.quad.get((usize::from(handle) + 2) % 4)?; - [a[0] - b[0], a[1] - b[1]] - } else { - let b = if handle.is_multiple_of(2) { t.quad[3] } else { t.quad[1] }; - [b[0] - t.quad[0][0], b[1] - t.quad[0][1]] + let Some(handle) = crate::type_tool::box_handle_at(app, id, q[0], q[1]) else { + // Just outside the text a press moves it (#5956): the Move cursor, as in Photoshop. + return move_zone(app, q).map(|_| CursorIcon::Move); }; - return Some(crate::tool_cursor::resize(direction, xf)); + let t = frame(app)?; + if handle < 4 { + return Some(crate::tool_cursor::corner_resize(&t.quad, usize::from(handle), xf)); + } + let b = if handle.is_multiple_of(2) { t.quad[3] } else { t.quad[1] }; + return Some(crate::tool_cursor::resize([b[0] - t.quad[0][0], b[1] - t.quad[0][1]], xf)); } let Some(t) = frame(app) else { return Some(CursorIcon::Default) }; let h = app.ui.type_transform.as_ref().map(|t| t.gesture.hit).or_else(|| hit(app, &t, xf.to_doc(p))); Some(match h { - Some(Hit::Corner(i)) => { - let q = *t.quad.get(i)?; - let opposite = *t.quad.get((i + 2) % 4)?; - crate::tool_cursor::resize([q[0] - opposite[0], q[1] - opposite[1]], xf) - } + Some(Hit::Corner(i)) => crate::tool_cursor::corner_resize(&t.quad, i, xf), Some(Hit::Edge(_)) => { // Original arrowhead indicator, above the canvas, without a proprietary cursor asset. let tokens = crate::theme::Tokens::get(ctx); diff --git a/crates/ui-egui/src/unsupported_marker_tests.rs b/crates/ui-egui/src/unsupported_marker_tests.rs new file mode 100644 index 0000000000..dc748381eb --- /dev/null +++ b/crates/ui-egui/src/unsupported_marker_tests.rs @@ -0,0 +1,42 @@ +//! #6541: the Layers panel marks an adjustment layer PhotoCraft keeps as imported but cannot +//! render, so a picture that differs from Photoshop's has a visible reason. + +use egui::vec2; +use egui_kittest::Harness; +use photocraft_doc::{Adjustment, Layer, LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::layer_row_ui::{Indicator, RowRects, recorded}; + +fn row(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> RowRects { + recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap() +} + +#[test] +fn an_unsupported_adjustment_row_carries_a_warning_marker() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let plain = LayerId(s.execute("layer.new.layer", json!({"name": "plain"})).unwrap()["layer"].as_u64().unwrap()); + let mystery = Layer::new("Mystery", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "xyzw".into(), raw: vec![1, 2, 3] })); + let mystery_id = mystery.id; + s.edit("unsupported fixture", |doc, active| { + doc.layers.push(mystery); + *active = Some(mystery_id); + Ok(()) + }) + .unwrap(); + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(8); + let marked = row(&h, mystery_id); + assert!(marked.indicators.iter().any(|(k, _)| *k == Indicator::Unsupported), "{:?}", marked.indicators); + assert!(!row(&h, plain).indicators.iter().any(|(k, _)| *k == Indicator::Unsupported)); + // The marker takes room from the name, never overlaps it. + let (_, marker) = marked.indicators.iter().find(|(k, _)| *k == Indicator::Unsupported).copied().unwrap(); + if let Some(name) = marked.name { + assert!(name.right() <= marker.left(), "name {name:?} runs under the marker {marker:?}"); + } +} diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index c62277fb1e..c818b75958 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -62,6 +62,101 @@ fn pointer(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool) { h.step(); } +fn stroke_width(h: &mut Harness<'_, PhotocraftApp>, width: &str) { + h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the stroke width").click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text(width.into())); + h.key_press(Key::Enter); + h.run_steps(3); +} + +/// #6191: passing through zero hides the stroke without discarding its paint or style. +#[test] +fn zero_width_preserves_selected_shape_stroke_and_restores_it() { + for depth in [8, 16, 32] { + for (properties, tool) in [(true, Tool::Rectangle), (false, Tool::Rectangle), (false, Tool::PathSelection), (false, Tool::DirectSelection)] { + let mut app = app("rgb", depth); + app.ui.tool = tool; + app.run( + "shape.create", + json!({"rect": [20, 20, 80, 60], "fill": null, + "stroke": {"width": 4, "color": [0.123456, 0.345678, 0.567891, 0.618237], + "align": "inside", "cap": "round", "join": "bevel", "miterLimit": 7, + "dashes": [3, 2], "dashOffset": 1.25, "opacity": 63}}), + ) + .unwrap(); + let original = shape(&app).clone(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, properties); + stroke_width(&mut h, "0"); + let zero = shape(h.state()).clone(); + let mut expected = original.stroke.clone().unwrap(); + expected.width = 0.0; + assert_eq!(zero.stroke, Some(expected), "{tool:?}, Properties={properties}, depth={depth}: zero width retains the stroke"); + assert_eq!(zero.path, original.path); + assert!(zero.cache.as_ref().unwrap().content_bounds().is_empty(), "a zero-width stroke paints nothing"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + + // A save at zero must retain the dormant stroke too. + let st = h.state().session.active().unwrap(); + let bytes = photocraft_format::save_to_bytes(&st.doc, &Default::default()).unwrap(); + let loaded = photocraft_format::load_from_bytes(&bytes).unwrap(); + let LayerContent::Shape(saved) = &loaded.layer(st.active_layer.unwrap()).unwrap().content else { panic!("saved shape") }; + assert_eq!(saved.stroke, zero.stroke); + + stroke_width(&mut h, "4"); + assert_eq!(shape(h.state()), &original, "restoring width restores the complete style and rendered pixels"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &zero); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &zero); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + } + } +} + +#[test] +fn zero_width_style_can_be_inspected_removed_or_reenabled_explicitly() { + for properties in [true, false] { + let mut app = app("rgb", 8); + app.run("shape.create", json!({"rect": [20, 20, 80, 60], "stroke": {"width": 4, "color": "#e73581", "dashes": [3, 2]}})).unwrap(); + let paint = shape(&app).stroke.as_ref().unwrap().paint.clone(); + let mut h = harness(app, properties); + stroke_width(&mut h, "0"); + let zero = shape(h.state()).clone(); + h.get_by_label("Stroke Options").click(); + h.run_steps(3); + assert_eq!(h.state().ui.stroke_editor.as_ref().unwrap().draft.dashes, [3.0, 2.0], "zero width still exposes the saved options"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(shape(h.state()), &zero, "merely inspecting the style does not enable it"); + + h.get_by_label(if properties { "Set shape stroke type" } else { "Stroke color" }).click(); + h.run_steps(3); + h.get_by_label("No Color").click(); + h.run_steps(2); + assert!(shape(h.state()).stroke.is_none(), "No Color still removes the stroke"); + h.key_press(Key::Escape); + h.run_steps(2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &zero); + h.run_steps(2); + + h.get_by_label("Stroke Options").click(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Dotted").click(); + h.run_steps(3); + let stroke = shape(h.state()).stroke.as_ref().unwrap(); + assert_eq!(stroke.width, 3.0, "a deliberate style choice enables the dormant stroke"); + assert_eq!(stroke.paint, paint); + assert_eq!(stroke.dashes, [0.0, 2.0]); + } +} + #[test] fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_paint() { for label in ["Fill color", "Stroke color"] { @@ -94,6 +189,84 @@ fn fill_and_stroke_swatches_open_with_pointer_and_keyboard_without_changing_pain } } +#[test] +fn shape_paint_hex_input_keeps_partial_text_and_preserves_alpha() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + let original = [0.123456, 0.345678, 0.567891, 0.372819]; + app.run("tools.setColors", json!({"foreground": original, "background": original})).unwrap(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + for ch in "12AB".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + assert_eq!(h.get_by_label("#").value().as_deref(), Some("12AB")); + assert_eq!(h.state().session.tools.foreground, original); + assert_eq!(h.state().session.tools.background, original); + for ch in "EF".chars() { + h.event(Event::Text(ch.to_string())); + h.run_steps(1); + } + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, original[3]]; + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + assert_eq!(if label == "Fill color" { tools.background } else { tools.foreground }, original); + // An invalid/multi-byte value leaves the selected paint untouched. + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Text("颜色".into())); + h.run_steps(2); + let tools = &h.state().session.tools; + assert_eq!(if label == "Fill color" { tools.foreground } else { tools.background }, want); + h.key_press(Key::Escape); + h.run_steps(2); + finish_shape(h.state_mut(), Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let drawn = shape(h.state()); + let paint = if label == "Fill color" { drawn.fill.as_ref().and_then(color_of) } else { drawn.stroke.as_ref().and_then(|s| color_of(&s.paint)) }; + assert_eq!(paint, Some(want), "the next shape takes the typed colour"); + } +} + +#[test] +fn a_pasted_hex_colour_updates_the_selected_shape_and_undoes_once() { + for label in ["Fill color", "Stroke color"] { + let mut app = app("rgb", 32); + app.run("tools.setColors", json!({"foreground": [0.2, 0.3, 0.4, 0.6], "background": [0.5, 0.6, 0.7, 0.8]})).unwrap(); + app.ui.tool_options.stroke_width = 3.0; + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let original = shape(&app).clone(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, false); + h.get_by_label(label).click(); + h.run_steps(3); + h.get_by_label("#").click(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(Event::Paste("#12ABEF".into())); + h.run_steps(2); + let edited = shape(h.state()); + let (paint, untouched) = if label == "Fill color" { + (edited.fill.as_ref().and_then(color_of), edited.stroke == original.stroke) + } else { + (edited.stroke.as_ref().and_then(|s| color_of(&s.paint)), edited.fill == original.fill) + }; + let alpha = if label == "Fill color" { 0.6 } else { 0.8 }; + let want = [18.0 / 255.0, 171.0 / 255.0, 239.0 / 255.0, alpha]; + assert_eq!(paint, Some(want)); + assert!(untouched, "the other paint stays unchanged"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(h.state()), &original); + } +} + #[test] fn picking_tool_colours_enables_paint_and_reaches_the_next_shape() { let mut app = app("rgb", 32); diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 205e47aaf9..56036a8520 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -26,6 +26,9 @@ pub struct PenPath { /// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put. #[serde(skip)] pub adjusting_last: bool, + /// ⌥/Alt-dragging the final anchor's incoming handle: it turns alone (#6077). + #[serde(skip)] + pub adjusting_in: bool, /// Drawn with the Curvature Pen: handles follow the anchors (`curvature_refresh`) and /// `unlinked` lists its corner points. #[serde(default)] @@ -33,6 +36,21 @@ pub struct PenPath { /// Curvature Pen: the anchor being dragged. #[serde(skip)] pub moving: Option, + /// Drawing carries on from an endpoint of this open subpath of a document path (#6593): the + /// commit replaces that subpath instead of adding one. + #[serde(default)] + pub resumes: Option, +} + +/// The subpath a Pen path carries on: where it is and what it was when drawing resumed. The +/// commit replaces it only while it is still that subpath of that document; otherwise (another +/// document, or the path changed meanwhile) the drawing is added as a new subpath. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Resumed { + pub doc: u64, + pub path: String, + pub index: usize, + pub was: photocraft_doc::vector::Subpath, } impl PenPath { @@ -222,8 +240,22 @@ pub fn closes_path_at(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { /// or add a new anchor (dragging a new anchor pulls symmetrical handles). With ⇧ a new anchor /// lands on the nearest 45° ray from the previous one, at the pointer's projection onto it. pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { + if !cancel_stale(app) { + return; + } let tol = 6.0 / app.point_zoom().max(0.01) as f64; + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } + // A press on an endpoint of an open path carries on drawing it, as in Photoshop (#6593): the + // press then acts on its last anchor, so a drag pulls out the next segment's handle. + if app.ui.pen.as_ref().is_none_or(|p| p.knots.is_empty()) + && let Some(resumed) = resume_at(app, x, y, tol) + { + app.ui.pen = Some(resumed); + } let pen = app.ui.pen.get_or_insert_with(PenPath::default); + pen.adjusting_in = false; if let Some(first) = pen.knots.first().map(|k| k[0]) && on_closing_anchor(pen, x, y, tol) { @@ -263,10 +295,14 @@ pub fn pen_move(app: &mut PhotocraftApp, x: f64, y: f64, shift: bool) { if let Some(pen) = app.ui.pen.as_mut() && pen.dragging { - let adjusting_last = pen.adjusting_last; + let (adjusting_last, adjusting_in) = (pen.adjusting_last, pen.adjusting_in); if let Some(k) = pen.knots.last_mut() { let a = k[0]; let [x, y] = shift_45(a, [x, y], shift); + if adjusting_in { + k[1] = [x, y]; + return; + } k[2] = [x, y]; if !adjusting_last { k[1] = [2.0 * a[0] - x, 2.0 * a[1] - y]; @@ -285,7 +321,20 @@ fn shift_45(origin: [f64; 2], p: [f64; 2], shift: bool) -> [f64; 2] { /// clicking the first anchor commits the path. Therefore Cmd/Ctrl+Z must consume one /// knot rather than undoing an unrelated, already committed document operation. pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { + if !cancel_stale(app) { + return false; + } let Some(pen) = app.ui.pen.as_mut() else { return false }; + // A resumed path's own anchors are not the draft's to take back: undoing past the ones added + // since it resumed drops the draft and leaves the path as it was (#6593). + if pen.resumes.as_ref().is_some_and(|r| pen.knots.len() <= r.was.knots.len()) { + // A ⌘/Ctrl selection on the draft goes with it. + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + app.ui.pen = None; + return true; + } if pen.knots.pop().is_none() { return false; } @@ -313,6 +362,7 @@ pub fn pen_up(app: &mut PhotocraftApp) { let Some(pen) = app.ui.pen.as_mut() else { return }; pen.dragging = false; pen.adjusting_last = false; + pen.adjusting_in = false; if closing(pen) { // A click on the first anchor closes the path and leaves that anchor's handles alone. if let Some(k) = pen.knots.last_mut() @@ -336,21 +386,31 @@ pub fn pen_break_handle(app: &mut PhotocraftApp) { pen.adjusting_last = true; } -/// ⌥/Alt-press on the last anchor's outgoing handle while drawing grabs that handle alone: the -/// drag turns it and the incoming curve stays put (#3306). False when the press misses it. +/// ⌥/Alt-press on one of the last anchor's handles while drawing grabs that handle alone: the +/// drag turns it and the other handle stays put (#3306, #6077). False when the press misses both. pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); let Some(pen) = app.ui.pen.as_mut() else { return false }; let Some((i, k)) = pen.knots.len().checked_sub(1).zip(pen.knots.last()) else { return false }; let dist = |p: [f64; 2]| (p[0] - x).hypot(p[1] - y); - if k[2] == k[0] || dist(k[2]) >= tol || dist(k[2]) >= dist(k[0]) { + // Both handles show only while the outgoing one is pulled out (`draw_overlay`). + if k[2] == k[0] { return false; } + let grabbed = |h: [f64; 2]| h != k[0] && dist(h) < tol && dist(h) < dist(k[0]); + let incoming = if grabbed(k[2]) && dist(k[2]) <= dist(k[1]) { + false + } else if grabbed(k[1]) { + true + } else { + return false; + }; if !pen.unlinked.contains(&i) { pen.unlinked.push(i); } pen.dragging = true; pen.adjusting_last = true; + pen.adjusting_in = incoming; true } @@ -414,6 +474,7 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { pen.unlinked = knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect(); pen.dragging = false; pen.adjusting_last = false; + pen.adjusting_in = false; pen.moving = None; if pen.curvature { curvature_refresh(pen, false); @@ -423,9 +484,50 @@ pub(crate) fn pen_replace(app: &mut PhotocraftApp, path: &Path) { } } +/// Preserve a foreign document's draft as its Work Path before another document can edit it. +/// A failed commit retains the draft and blocks the caller until it can be retried. +pub fn cancel_stale(app: &mut PhotocraftApp) -> bool { + let active = app.session.active().map(|st| st.doc.id); + if app.ui.pen.is_none() { + app.ui.pen_doc = None; + return true; + } + let Some(owner) = app.ui.pen_doc.filter(|d| Some(*d) != active) else { return true }; + if let Some(index) = app.session.documents().iter().position(|d| d.doc.id == owner) { + let Some(mut pen) = app.ui.pen.clone() else { return true }; + if pen.curvature { + curvature_refresh(&mut pen, false); + } + let active = app.session.active_index(); + app.session.set_active(index); + // Bypass shell targets and sync_views: both belong to the newly active document. + let result = app.session.execute("path.set", json!({"name": "work", "op": "combine", "path": pen_to_json(&pen, false)})); + if let Some(index) = active { + app.session.set_active(index); + } + if let Err(e) = result { + app.ui.status = e.to_string(); + app.ui.status_error = true; + return false; + } + } + app.ui.pen = None; + app.ui.pen_doc = None; + if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { + app.ui.direct_selection = Default::default(); + } + true +} + /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { + // The control channel is drained after `sync_views`, so a document switch in this frame has + // not been seen yet: never commit the path into the document it was not drawn on. + if !cancel_stale(app) { + return; + } let Some(mut pen) = app.ui.pen.take() else { return }; + app.ui.pen_doc = None; if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) { // Its anchors are no longer the Pen's: the committed path is another path. app.ui.direct_selection = Default::default(); @@ -454,6 +556,11 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { stroke }; app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke})) + } else if let Some((name, p)) = + pen.resumes.as_ref().and_then(|r| app.session.active().and_then(|st| with_resumed_subpath(&st.doc, r, &path)).map(|p| (r.path.clone(), p))) + { + // The resumed subpath, carried on, takes its old place: one step, no second subpath. + app.run("path.set", json!({"name": name, "path": p})).inspect(|_| app.ui.selected_path = Some(name)) } else if let Some((id, existing)) = targeted_vector_mask(app) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. let mut p = photocraft_engine::vector_cmds::path_json(&existing); @@ -465,7 +572,11 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // Photoshop adds the new subpath to the path selected in the Paths panel, which is the // Work Path right after drawing one, so path after path builds up there (#4721). With no // path selected, a new Work Path replaces the old one. The drawn path ends up selected. - let target = app.ui.selected_path.clone().filter(|n| app.session.active().is_some_and(|st| path_exists(&st.doc, n))); + // A resumed draft whose subpath changed meanwhile joins the path it came from, as a new + // subpath, even with nothing selected in the Paths panel. + let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n)); + let resumed_from = pen.resumes.as_ref().filter(|r| app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone()); + let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists)); let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); if target.is_some() { @@ -481,7 +592,62 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { /// Does `doc` have the path the Paths panel calls `name`: "work", or a saved path's name? fn path_exists(doc: &Document, name: &str) -> bool { - if name == "work" { doc.work_path.is_some() } else { doc.paths.iter().any(|p| p.name == name) } + doc_path(doc, name).is_some() +} + +/// The path the Paths panel calls `name`: "work", or a saved path's name. +fn doc_path<'a>(doc: &'a Document, name: &str) -> Option<&'a Path> { + if name == "work" { doc.work_path.as_ref() } else { doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) } +} + +/// The open subpath of the path a new Pen path would join (the one selected in the Paths panel, +/// else the work path) that has its first or last anchor at `(x, y)`, as a Pen path to carry on +/// drawing from there (#6593). Its knots run towards that anchor: reversed, handles swapped, when +/// it is the first. Shape mode and a targeted vector mask take new paths, so they resume nothing. +fn resume_at(app: &PhotocraftApp, x: f64, y: f64, tol: f64) -> Option { + if app.ui.tool_options.vector_mode == "shape" || targeted_vector_mask(app).is_some() { + return None; + } + let st = app.session.active()?; + let doc = st.doc.id.0; + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let near = |k: &photocraft_doc::Knot| (k.anchor.x - x).hypot(k.anchor.y - y) < tol; + doc_path(&st.doc, &name)?.subpaths.iter().enumerate().filter(|(_, s)| !s.closed).find_map(|(i, s)| { + let reversed = if s.knots.last().is_some_and(near) { + false + } else if s.knots.first().is_some_and(near) { + true + } else { + return None; + }; + let mut knots: Vec<_> = s.knots.iter().map(|k| (knot_points(k), k.smooth)).collect(); + if reversed { + knots.reverse(); + for (k, _) in &mut knots { + k.swap(1, 2); + } + } + let unlinked = knots.iter().enumerate().filter(|(_, (_, smooth))| !smooth).map(|(i, _)| i).collect(); + let resumes = Resumed { doc, path: name.clone(), index: i, was: s.clone() }; + Some(PenPath { knots: knots.into_iter().map(|(k, _)| k).collect(), unlinked, resumes: Some(resumes), ..Default::default() }) + }) +} + +/// The resumed path with its subpath carried on: the one subpath of `drawn` takes the old one's +/// anchors and closing, which keeps its other settings (its path operation). `None` when the +/// subpath is no longer the one drawing resumed (another document, or the path changed). +fn with_resumed_subpath(doc: &Document, r: &Resumed, drawn: &Value) -> Option { + let path = doc_path(doc, &r.path).filter(|_| doc.id.0 == r.doc)?; + if path.subpaths.get(r.index) != Some(&r.was) { + return None; + } + let mut p = photocraft_engine::vector_cmds::path_json(path); + let slot = p.get_mut("subpaths")?.as_array_mut()?.get_mut(r.index)?.as_object_mut()?; + let new = drawn.get("subpaths")?.get(0)?; + for key in ["closed", "knots"] { + slot.insert(key.into(), new.get(key)?.clone()); + } + Some(p) } // --------------------------------------------------------------------------------------------- @@ -592,6 +758,9 @@ fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { /// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor /// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { + if !cancel_stale(app) { + return; + } if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { pen_commit(app, false); } @@ -600,6 +769,9 @@ pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { pen_commit(app, true); return; } + if app.ui.pen.is_none() { + app.ui.pen_doc = app.session.active().map(|st| st.doc.id); + } let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); if hit.is_none() { pen.knots.push([[x, y]; 3]); @@ -986,7 +1158,15 @@ pub(crate) fn shows_anchors(tool: Tool) -> bool { matches!(tool, Tool::PathSelection | Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen) } -/// Work path / active shape path outlines, anchors, and the pen path in progress. +/// The saved path selected in the Paths panel: Photoshop's target path, drawn on the canvas with +/// any tool (#6550). `None` when the panel selects the work path, a layer's path or nothing. +pub(crate) fn selected_saved_path(app: &PhotocraftApp) -> Option<&Path> { + let sel = app.ui.selected_path.as_deref().filter(|s| !matches!(*s, "work" | "layer"))?; + app.session.active()?.doc.paths.iter().find(|p| p.name == sel).map(|p| &p.path) +} + +/// Work path / active shape path / selected saved path outlines, anchors, and the pen path in +/// progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; let accent = Tokens::get(painter.ctx()).accent; @@ -1002,19 +1182,32 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // View › Show › Target Path (under Extras) hides the paths; the Pen's path in progress stays. // Free Transform Path draws the path through its box instead (transform_tool). let paths = app.ui.view.shows(app.ui.view.show.target_path) && app.ui.transform.as_ref().is_none_or(|t| t.path.is_none()); - if crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { + if app.session.active().is_some_and(|st| st.doc.id == doc.id) && crate::direct_select::shows(app, painter.ctx().input(|i| i.modifiers)) { // Direct Selection (or the Pen with ⌘/Ctrl held) draws the paths it edits (#790). crate::direct_select::draw_overlay(app, painter, &to_scr, accent, paths); if paths { + // The selected saved path shows too, though Direct Selection doesn't edit it yet. + if let Some(p) = selected_saved_path(app) { + draw_path(p, true); + } crate::anchor_tools::draw_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } } else if paths { - // Paths remain visible as outlines with any tool. Path Selection and the Pen tools add - // anchor squares; Direct Selection has its own overlay above. + // The shape's or vector mask's path stays visible as an outline with any tool. Path + // Selection and the Pen tools add anchor squares; Direct Selection has its own overlay + // above. The Work Path is drawn only while it is the path selected in the Paths panel or + // a path tool is active, as in Photoshop: a Work Path left unselected (one a PSD arrived + // with, or after Ctrl-click Load as Selection) would otherwise cover the marching ants (#6551). let anchors = shows_anchors(tool); - if let Some(wp) = &doc.work_path { + if let Some(wp) = &doc.work_path + && (anchors || app.ui.selected_path.as_deref() == Some("work")) + { draw_path(wp, anchors); } + // The saved path selected in the Paths panel is the target path (#6550). + if let Some(p) = selected_saved_path(app) { + draw_path(p, anchors); + } let st = app.session.active(); if let Some(l) = st.and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) && let LayerContent::Shape(sh) = &l.content @@ -1030,7 +1223,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While // ⌘/Ctrl drags it, Direct Selection draws its preview instead. let edited = matches!(&app.ui.direct_selection.drag, Some(crate::direct_select::Drag::Edit { target: crate::direct_select::PathRef::Pen, .. })); - if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited) { + // Only on the document it is drawn on: a view of another document never shows it (#5373). + if let Some(pen) = app.ui.pen.as_ref().filter(|_| !edited).filter(|_| app.ui.pen_doc.is_none_or(|d| d == doc.id)) { let mut pts = Vec::new(); // A close-drag shows the first segment bending with the handle being pulled out. let mut knots = pen.knots.clone(); @@ -1180,6 +1374,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ), ), } + // Path mode: Make Selection from the path being drawn or the active path, as Photoshop's + // options bar's "Make: Selection…" does (#5703). + if app.ui.tool_options.vector_mode == "path" { + crate::widgets::vline(ui, 22.0); + let has_path = active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); + if ui.add_enabled(has_path, egui::Button::new(tl!("Make Selection"))).clicked() + && let Err(e) = crate::canvas_tool_menu::make_selection(app) + { + app.ui.status = e; + app.ui.status_error = true; + } + } return true; } let mut mode = "shape".to_string(); @@ -1192,8 +1398,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo None => (o.shape_fill, o.shape_fill.then(|| solid_paint(fg)), (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, solid_paint(bg)))), }; let mut width = stroke.as_ref().map_or(0.0, |s| s.width); + let stroke_enabled = stroke.as_ref().is_some_and(|s| s.width > 0.0); let style = stroke.as_ref().map_or_else(|| o.shape_stroke.clone(), crate::shape_stroke_ui::StrokeOptions::from); - let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}", s.doc, s.layer.0)); + let gesture_key = ui.id().with("shape-options-paint-gesture"); + let gesture = paint_gesture(ui, gesture_key); + let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}:{}:{gesture}", s.doc, s.layer.0, gesture_key.value())); let mut edit: Option = None; lbl(ui, tl!("Fill:")); if crate::widgets::checkbox(ui, &mut has_fill, "").changed() { @@ -1209,15 +1418,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo if stroke.is_none() { p["color"] = json!(bg); } - edit = Some(if width <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": p, "coalesce": key("stroke-w")}) }); + edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")})); } let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer)); if let Some(change) = - crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke.is_some()) + crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled) { let mut p = change.options.params(); - if stroke.is_none() { + if !stroke_enabled { p["width"] = json!(3.0); + } + if stroke.is_none() { p["color"] = json!(bg); } edit = Some(json!({"stroke": p, "coalesce": change.coalesce})); @@ -1288,6 +1499,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo app.ui.status = e; app.ui.status_error = true; } + if !ui.input(|i| i.pointer.any_down()) { + ui.data_mut(|d| { + let gesture = d.get_temp_mut_or_default::<(u64, u64)>(gesture_key); + gesture.0 = gesture.0.wrapping_add(1); + }); + } } true } @@ -1446,6 +1663,37 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 out = Some(PaintChange::Color(color)); } } + ui.horizontal(|ui| { + let label = ui.label(egui::RichText::new("#").color(t.text_dim)); + let id = resp.id.with("shape-paint-hex"); + let selected = match out { + Some(PaintChange::Color(color)) => color, + _ => original, + }; + // Keep incomplete typing across frames; deriving the field from the colour each + // frame would discard every keystroke until all six digits were present. + let mut text = ui + .data(|d| d.get_temp::<([f32; 4], String)>(id)) + .filter(|(previous, _)| *previous == selected) + .map_or_else(|| hex(selected).trim_start_matches('#').to_ascii_uppercase(), |(_, text)| text); + let response = crate::widgets::text_field(ui, id, &mut text, "RRGGBB", 90.0, true).labelled_by(label.id); + if response.changed() + && let Some(rgb) = crate::color_picker_ui::parse_hex(&text) + { + let color = [rgb[0], rgb[1], rgb[2], selected[3]]; + out = Some(PaintChange::Color(color)); + hsva = picker_color(color); + } + if response.has_focus() { + let color = match out { + Some(PaintChange::Color(color)) => color, + _ => selected, + }; + ui.data_mut(|d| d.insert_temp(id, (color, text))); + } else { + ui.data_mut(|d| d.remove::<([f32; 4], String)>(id)); + } + }); if let Some(rgb) = crate::screen_picker::button(ui, screen_id) { out = Some(sampled(rgb)); hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]); @@ -1528,8 +1776,7 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra } let mut w = sh.stroke.as_ref().map_or(0.0, |s| s.width); if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() { - let stroke = if w <= 0.0 { Value::Null } else { json!({"width": w}) }; - edit = Some(json!({"stroke": stroke, "coalesce": coalesce("stroke-w")})); + edit = Some(json!({"stroke": {"width": w}, "coalesce": coalesce("stroke-w")})); } }); let style = sh.stroke.as_ref().map(crate::shape_stroke_ui::StrokeOptions::from).unwrap_or_default(); @@ -1595,7 +1842,14 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra }); } } - photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, .. } => { + photocraft_doc::vector::LiveShape::Polygon { sides, star_ratio, corner_radius, .. } => { + // One radius rounds every vertex of a triangle, polygon or star, as in Photoshop. + ui.horizontal(|ui| { + let mut r = *corner_radius as f32; + if num(ui, tl!("Corner radius"), &mut r, 0.0..=100000.0, "px") { + edit = Some(json!({"cornerRadius": r, "coalesce": key("corner-radius")})); + } + }); ui.horizontal(|ui| { let mut n = *sides as f32; if num(ui, tl!("Sides"), &mut n, 3.0..=100.0, "") { @@ -1696,6 +1950,19 @@ fn footer_id() -> egui::Id { egui::Id::new("paths-footer") } +/// A Paths panel row being dragged, for a drop on the trash (#6553): its name and the command +/// that deletes it. +#[derive(Clone, Debug)] +struct PathDrag { + name: String, + cmd: &'static str, + params: Value, +} + +fn path_drag_key() -> egui::Id { + egui::Id::new("paths-drag") +} + fn ctx_data_footer(ctx: &egui::Context, r: Rect) { ctx.data_mut(|d| d.insert_temp(footer_id(), r)); } @@ -1705,9 +1972,36 @@ pub fn paths_footer(ctx: &egui::Context) -> Option { ctx.data(|d| d.get_temp(footer_id())) } +/// Photoshop's default name for a saved path: the first free "Path N", counting from the number of +/// saved paths. +fn free_path_name(doc: &Document) -> String { + let mut n = doc.paths.len().saturating_add(1); + while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { + n = n.saturating_add(1); + if n == usize::MAX { + break; + } + } + format!("Path {n}") +} + +/// A Paths panel name being typed in place: the row's key ("work" or the saved path's name) and +/// the text so far. It lives in egui's temp data, like the Channels panel's rename field. +#[derive(Clone)] +struct PathRename { + key: String, + text: String, + focused: bool, +} + +fn rename_id(doc: &Document) -> egui::Id { + egui::Id::new(("path-rename", doc.id.0)) +} + /// Commands offered by a Paths row's context menu. The row's path is passed explicitly so a -/// right-click acts on that row even when another path is selected in the panel. -fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, &'static str, Value)> { +/// right-click acts on that row even when another path is selected in the panel. `layer` is the +/// layer a `PathRow::Layer` row belongs to (the active layer). +fn path_context_actions(entry: &PathEntry, doc: &Document, layer: Option) -> Vec<(&'static str, &'static str, Value)> { let key = match entry.kind { PathRow::Work => "work".to_string(), PathRow::Layer => "layer".to_string(), @@ -1719,14 +2013,7 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, ("Stroke Path…", "path.stroke", json!({"name": key})), ]; if entry.kind == PathRow::Work { - let mut n = doc.paths.len().saturating_add(1); - while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { - n = n.saturating_add(1); - if n == usize::MAX { - break; - } - } - actions.push(("Save Path", "path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + actions.push(("Save Path", "path.rename", json!({"name": "work", "to": free_path_name(doc)}))); } else { let mut n = 1usize; while doc.paths.iter().any(|p| p.name == format!("{} copy {n}", entry.name)) { @@ -1738,10 +2025,17 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, let copy_name = format!("{} copy {n}", entry.name); actions.push(("Duplicate Path", "path.set", json!({"name": copy_name, "path": photocraft_engine::vector_cmds::path_json(&entry.path)}))); } + if entry.kind == PathRow::Saved { + // No `to`: the panel asks for the new name in place. + actions.push(("Rename Path…", "path.rename", json!({"name": key}))); + } if entry.kind != PathRow::Layer { actions.push(("Delete Path", "path.delete", json!({"name": key}))); } else if entry.name.ends_with(" Vector Mask") { actions.push(("Delete Vector Mask", "layer.vectorMask.delete", json!({}))); + } else if let Some(id) = layer { + // A shape layer's path is the shape: Photoshop's Delete Path deletes the layer (#6552). + actions.push(("Delete Path", "layer.delete", json!({"layer": id.0}))); } actions } @@ -1753,10 +2047,19 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; }; let doc = st.doc.clone(); - let rows = path_rows(&doc, st.active_layer); + let st_layer = st.active_layer; + let rows = path_rows(&doc, st_layer); let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; let mut stroke_dialog: Option = None; + // A row dragged onto the trash is deleted (#6553); a drag that never saw its release must not + // outlive it. + let ctx = ui.ctx().clone(); + if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(path_drag_key())); + } + let drag = ctx.data(|d| d.get_temp::(path_drag_key())); + let rename_id = rename_id(&doc); // The buttons sit in a footer at the panel's bottom, like Photoshop's. let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; @@ -1768,12 +2071,26 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(tl!("Draw with the Pen tool (P) or make a work path from a selection.")).color(t.text_faint).size(11.5)); } for PathEntry { name, path, kind } in &rows { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + // What dropping the row on the trash runs; a shape layer's path is not dragged. + let delete = match kind { + PathRow::Work => Some(("path.delete", json!({"name": "work"}))), + PathRow::Saved => Some(("path.delete", json!({"name": name}))), + PathRow::Layer if name.ends_with(" Vector Mask") => Some(("layer.vectorMask.delete", json!({}))), + PathRow::Layer => None, + }; + let sense = if delete.is_some() { Sense::click_and_drag() } else { Sense::click() }; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), sense); let key = match kind { PathRow::Work => "work".to_string(), PathRow::Layer => "layer".to_string(), PathRow::Saved => name.clone(), }; + if resp.drag_started() + && let Some((cmd, params)) = delete + { + let name = if *kind == PathRow::Work { tl!(name).to_string() } else { name.clone() }; + ui.ctx().data_mut(|d| d.insert_temp(path_drag_key(), PathDrag { name, cmd, params })); + } // Rows are painted: name them for screen readers and UI tests. resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, true, name)); let sel = app.ui.selected_path.as_deref() == Some(key.as_str()); @@ -1792,8 +2109,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { 0.0, egui::TextFormat { font_id: egui::FontId::proportional(12.0), color: t.text, italics, ..Default::default() }, ); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + let renaming = ui.ctx().data(|d| d.get_temp::(rename_id)).filter(|e| e.key == key); + if let Some(mut edit) = renaming { + let field = Rect::from_min_max(pos2(r.left() + 43.0, r.center().y - 11.0), pos2(r.right() - 8.0, r.center().y + 11.0)); + let id = rename_id.with("field"); + let te = ui.put(field, egui::TextEdit::singleline(&mut edit.text).id(id).font(egui::FontId::proportional(12.0))); + let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); + if !edit.focused { + te.request_focus(); + // Like Photoshop, the whole name starts selected so typing replaces it. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(edit.text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + edit.focused = true; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } else if esc { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + } else if enter || te.lost_focus() || !te.has_focus() { + ui.ctx().data_mut(|d| d.remove::(rename_id)); + let to = edit.text.trim(); + // The Work Path is saved even under its proposed name; an unchanged saved name is left alone. + if !to.is_empty() && (*kind == PathRow::Work || to != name) { + action = Some(("path.rename", json!({"name": key, "to": to}))); + } + } else { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); + } + } else { + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(r.left() + 46.0, r.center().y - g.size().y / 2.0), g, t.text); + } // Ctrl/Cmd-click loads only the thumbnail's path, without changing the panel target. let modifiers = ui.input(|i| i.modifiers); let load = resp.clicked() && modifiers.command && resp.interact_pointer_pos().is_some_and(|p| thumbnail.contains(p)); @@ -1806,22 +2153,33 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if resp.clicked() { app.ui.selected_path = Some(key.clone()); } - // Double-clicking the Work Path saves it (Photoshop's "Save Path"). - if !load && resp.double_clicked() && *kind == PathRow::Work { - let n = doc.paths.len() + 1; - action = Some(("path.rename", json!({"name": "work", "to": format!("Path {n}")}))); + // As in Photoshop, double-clicking a saved path's name renames it, and double-clicking + // the Work Path asks for the name to save it under (#6238). + let proposed = match kind { + PathRow::Work => Some(free_path_name(&doc)), + PathRow::Saved => Some(name.clone()), + PathRow::Layer => None, + }; + if !load + && resp.double_clicked() + && let Some(text) = proposed + { + ui.ctx().data_mut(|d| d.insert_temp(rename_id, PathRename { key: key.clone(), text, focused: false })); } resp.context_menu(|ui| { crate::widgets::menu_scroll(ui, |ui| { ui.set_min_width(190.0); let entry = PathEntry { name: name.clone(), path: path.clone(), kind: *kind }; let can_paint = app.session.active().and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))).is_some_and(|l| l.surface().is_some()); - for (label, cmd, params) in path_context_actions(&entry, &doc) { + for (label, cmd, params) in path_context_actions(&entry, &doc, st_layer) { let enabled = !matches!(cmd, "path.fill" | "path.stroke") || can_paint; if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() { if cmd == "path.stroke" { // Stroke Path… asks for the tool first, as in Photoshop (#2978). stroke_dialog = Some(params.get("name").and_then(Value::as_str).unwrap_or("work").to_string()); + } else if cmd == "path.rename" && params.get("to").is_none() { + let edit = PathRename { key: key.clone(), text: name.clone(), focused: false }; + ui.ctx().data_mut(|d| d.insert_temp(rename_id, edit)); } else { action = Some((cmd, params)); } @@ -1841,6 +2199,12 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }, ); + // The dragged row follows the cursor. + if let Some(drag) = &drag + && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) + { + crate::layer_transfer::ghost(&ctx, p, &drag.name); + } let sel = app.ui.selected_path.clone().filter(|s| s != "layer" || has_layer_path).unwrap_or_else(|| "work".into()); let buttons = crate::widgets::panel_footer(ui, |ui| { let fg = hex(app.session.tools.foreground); @@ -1876,19 +2240,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { action = Some((cmd, p)); } } + // A row dropped on the trash deletes that row's path, selected or not (#6553). + if matches!(cmd, "path.delete" | "layer.vectorMask.delete") + && let Some(drag) = &drag + && crate::panels::dropped_on(ui, &b) + { + action = Some((drag.cmd, drag.params.clone())); + } buttons = buttons.union(b.rect); } buttons }); ctx_data_footer(ui.ctx(), buttons); + if ctx.input(|i| i.pointer.any_released()) { + ctx.data_mut(|d| d.remove::(path_drag_key())); + } if let Some(name) = stroke_dialog { crate::canvas_tool_menu::stroke_path_dialog(app, &name); } - if let Some((cmd, p)) = action - && let Err(e) = app.run(cmd, p) - { - app.ui.status = e; - app.ui.status_error = true; + if let Some((cmd, p)) = action { + let renamed = if cmd == "path.rename" { p.get("to").and_then(Value::as_str).map(str::to_string) } else { None }; + match app.run(cmd, p) { + // The renamed (or just saved) path stays the panel's target. + Ok(_) => { + if renamed.is_some() { + app.ui.selected_path = renamed; + } + } + Err(e) => { + app.ui.status = e; + app.ui.status_error = true; + } + } } } @@ -2001,7 +2384,7 @@ mod tests { app.run("path.set", json!({"name": "Second", "path": path})).unwrap(); let doc = &app.session.active().unwrap().doc; let first = path_rows(doc, None).into_iter().find(|r| r.name == "First").unwrap(); - let entries = path_context_actions(&first, doc); + let entries = path_context_actions(&first, doc, None); assert_eq!(entries[0].1, "path.toSelection"); assert_eq!(entries[0].2["name"], "First"); assert!(entries.iter().all(|(_, id, _)| photocraft_engine::commands::find(id).is_some())); @@ -2013,7 +2396,7 @@ mod tests { app.run("path.set", json!({"name": "First copy 1", "path": path})).unwrap(); let doc = &app.session.active().unwrap().doc; let first = path_rows(doc, None).into_iter().find(|r| r.name == "First").unwrap(); - let (_, _, duplicate) = path_context_actions(&first, doc).into_iter().find(|(_, id, _)| *id == "path.set").unwrap(); + let (_, _, duplicate) = path_context_actions(&first, doc, None).into_iter().find(|(_, id, _)| *id == "path.set").unwrap(); assert_eq!(duplicate["name"], "First copy 2", "duplicate must not overwrite an existing path"); } @@ -2024,14 +2407,90 @@ mod tests { app.run("path.set", json!({"name": "work", "path": path})).unwrap(); let doc = &app.session.active().unwrap().doc; let work = path_rows(doc, None).into_iter().find(|r| r.kind == PathRow::Work).unwrap(); - let entries = path_context_actions(&work, doc); + let entries = path_context_actions(&work, doc, None); assert!(entries.iter().any(|(_, id, _)| *id == "path.rename")); assert!(!entries.iter().any(|(_, id, _)| *id == "path.set")); let layer = PathEntry { kind: PathRow::Layer, ..work }; - let entries = path_context_actions(&layer, doc); + let entries = path_context_actions(&layer, doc, None); assert!(!entries.iter().any(|(_, id, _)| *id == "path.delete")); } + /// #6552: a shape layer's " Shape Path" row offers Delete Path, and it deletes the + /// shape layer, as Photoshop's Delete Path does (measured: one "Delete Layer" step). + #[test] + fn shape_path_row_delete_path_deletes_the_shape_layer() { + let mut app = app(); + app.run("shape.create", json!({"kind": "ellipse", "rect": [20, 20, 100, 70]})).unwrap(); + let st = app.session.active().unwrap(); + let (doc, shape) = (st.doc.clone(), st.active_layer); + let layers = doc.layers.len(); + let row = path_rows(&doc, shape).into_iter().find(|r| r.kind == PathRow::Layer).unwrap(); + assert!(row.name.ends_with(" Shape Path"), "{}", row.name); + let (_, id, params) = + path_context_actions(&row, &doc, shape).into_iter().find(|(label, _, _)| *label == "Delete Path").expect("Delete Path is offered"); + app.run(id, params).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.layers.len(), layers - 1); + assert!(shape.and_then(|id| doc.layer(id)).is_none(), "the shape layer is gone"); + app.run("edit.undo", json!({})).unwrap(); + assert!(shape.and_then(|id| app.session.active().unwrap().doc.layer(id)).is_some(), "one undo step brings it back"); + } + + /// #6238: double-clicking a saved path's name renames it in place, and double-clicking the + /// Work Path saves it under the name typed (it used to save it as "Path N" straight away). + #[test] + fn double_clicking_a_path_name_renames_it_in_place() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"name": "First", "path": path})).unwrap(); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| paths_panel(app, ui), app); + h.run_steps(3); + let rename = |h: &mut Harness<'_, PhotocraftApp>, row: &str, text: &str| { + let name = h.get_by_label(row).rect().left_center() + vec2(60.0, 0.0); + h.event(egui::Event::PointerMoved(name)); + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + }; + let names = |h: &Harness<'_, PhotocraftApp>| h.state().session.active().unwrap().doc.paths.iter().map(|p| p.name.clone()).collect::>(); + rename(&mut h, "First", "Outline"); + assert_eq!(names(&h), ["Outline"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_some(), "renaming a saved path leaves the work path alone"); + rename(&mut h, "Work Path", "Circle"); + assert_eq!(names(&h), ["Outline", "Circle"]); + assert!(h.state().session.active().unwrap().doc.work_path.is_none()); + assert_eq!(h.state().ui.selected_path.as_deref(), Some("Circle")); + } + + /// #6238: a saved path's context menu offers Rename Path…, as the Layers panel's does. + #[test] + fn saved_path_context_menu_offers_rename() { + let mut app = app(); + app.run("path.set", json!({"name": "First", "path": {"subpaths": []}})).unwrap(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": []}})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let rename = |kind| { + let entry = path_rows(doc, None).into_iter().find(|r| r.kind == kind).unwrap(); + path_context_actions(&entry, doc, None).into_iter().find(|(label, _, _)| *label == "Rename Path…").map(|(_, id, p)| (id, p)) + }; + assert_eq!(rename(PathRow::Saved), Some(("path.rename", json!({"name": "First"})))); + assert_eq!(rename(PathRow::Work), None, "the work path is saved, not renamed"); + } + /// How many shapes the path overlay paints for `app` this frame. fn overlay_shapes(app: &PhotocraftApp, ctx: &egui::Context) -> usize { let doc = app.session.active().unwrap().doc.clone(); @@ -2069,6 +2528,43 @@ mod tests { assert!(!shows_anchors(Tool::Brush) && !shows_anchors(Tool::Move)); } + #[test] + fn an_unselected_work_path_is_not_drawn_over_the_canvas() { + // #6551: Ctrl-click Load as Selection leaves the Paths panel's selection alone, yet the Work + // Path stayed drawn over the new selection's edge. Photoshop draws it only while selected + // in the Paths panel (or a path tool is active). + let ctx = egui::Context::default(); + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [20, 20], "in": [20, 20], "out": [20, 20]}, + {"anchor": [90, 20], "in": [90, 20], "out": [90, 20]}, + {"anchor": [90, 90], "in": [90, 90], "out": [90, 90]} + ]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.run("path.toSelection", json!({"name": "work", "mode": "replace"})).unwrap(); + assert!(app.ui.selected_path.is_none()); + for tool in [Tool::Move, Tool::Brush, Tool::Rectangle, Tool::Zoom] { + app.ui.tool = tool; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: an unselected Work Path stays off the canvas"); + } + // Selected in the Paths panel: drawn with any tool. + app.ui.selected_path = Some("work".into()); + for tool in [Tool::Move, Tool::Brush] { + app.ui.tool = tool; + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the selected Work Path is drawn"); + } + // Another path selected: the Work Path is not the target path. + app.ui.selected_path = Some("Saved".into()); + app.ui.tool = Tool::Move; + assert_eq!(overlay_shapes(&app, &ctx), 0); + // A path tool shows the Work Path it edits even when the panel selection was cleared. + app.ui.selected_path = None; + for tool in [Tool::Pen, Tool::PathSelection] { + app.ui.tool = tool; + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: path tools draw the Work Path"); + } + } + #[test] fn show_target_path_and_extras_hide_the_path_outlines() { // #1119: View › Show › Target Path only flipped its checkmark; the canvas kept drawing the @@ -2105,6 +2601,46 @@ mod tests { assert!(overlay_shapes(&app, &ctx) > 0, "the pen path in progress is still drawn"); } + #[test] + fn the_saved_path_selected_in_the_paths_panel_is_drawn() { + // #6550: selecting a saved path in the Paths panel drew nothing on the canvas. + let ctx = egui::Context::default(); + let mut app = app(); + let tri = |o: f64| { + json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [20.0 + o, 20], "in": [20.0 + o, 20], "out": [20.0 + o, 20]}, + {"anchor": [90.0 + o, 20], "in": [90.0 + o, 20], "out": [90.0 + o, 20]}, + {"anchor": [90.0 + o, 90], "in": [90.0 + o, 90], "out": [90.0 + o, 90]} + ]}]}) + }; + app.run("path.set", json!({"name": "Path 1", "path": tri(0.0)})).unwrap(); + app.run("path.set", json!({"name": "Path 2", "path": tri(10.0)})).unwrap(); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + for tool in [Tool::Move, Tool::Brush, Tool::Pen, Tool::PathSelection, Tool::DirectSelection] { + app.ui.tool = tool; + app.ui.selected_path = None; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: nothing selected, nothing drawn"); + app.ui.selected_path = Some("Path 1".into()); + assert_eq!(selected_saved_path(&app).map(|p| p.subpaths[0].knots[0].anchor.x), Some(20.0)); + assert!(overlay_shapes(&app, &ctx) > 0, "{tool:?}: the selected saved path is drawn"); + app.ui.selected_path = Some("Path 2".into()); + assert_eq!(selected_saved_path(&app).map(|p| p.subpaths[0].knots[0].anchor.x), Some(30.0)); + app.ui.selected_path = Some("Gone".into()); + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: a missing path draws nothing"); + app.ui.selected_path = Some("Path 1".into()); + app.ui.view.show.target_path = false; + assert_eq!(overlay_shapes(&app, &ctx), 0, "{tool:?}: View > Show > Target Path off"); + app.ui.view.show.target_path = true; + } + // Path Selection (and the Pen tools, as for the work path) add anchor squares. + app.ui.tool = Tool::Move; + let plain = overlay_shapes(&app, &ctx); + app.ui.tool = Tool::PathSelection; + assert!(overlay_shapes(&app, &ctx) > plain); + app.ui.tool = Tool::Pen; + assert!(overlay_shapes(&app, &ctx) > plain); + } + #[test] fn shape_drag_creates_layers_with_modifiers() { let mut app = app(); @@ -2271,6 +2807,287 @@ mod tests { pen_up(&mut app); } + /// #5373: an unfinished path stays with its own document through same-frame switches. + #[test] + fn an_unclosed_path_stays_with_its_document() { + // Both pens claim the document at their first anchor. + let open_path = |app: &mut PhotocraftApp| { + for (x, y) in [(20.0, 20.0), (120.0, 60.0)] { + if app.ui.tool == Tool::CurvaturePen { + curvature_down(app, x, y); + curvature_up(app); + } else { + pen_down(app, x, y, false); + pen_up(app); + } + } + }; + let work = |app: &PhotocraftApp, i: usize| app.session.documents()[i].doc.work_path.clone(); + // File › New commits the path to its original document. + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + open_path(&mut app); + assert!(app.ui.pen.is_some() && app.ui.pen_doc.is_some(), "{tool:?}: claimed by its document"); + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + assert!(app.ui.pen.is_none() && app.ui.pen_doc.is_none(), "{tool:?}: the first document's path is gone"); + assert!(work(&app, 0).is_some(), "{tool:?}: the original keeps its Work Path"); + assert_eq!(work(&app, 1), None, "{tool:?}: the new document is blank"); + } + // Two documents; the path below is drawn on the second (active) one. + let mut app = app(); + app.ui.tool = Tool::Pen; + app.run("file.new", json!({"width": 300, "height": 150})).unwrap(); + open_path(&mut app); + assert!(app.session.set_active(0)); + pen_commit(&mut app, false); + assert!(app.ui.pen.is_none()); + assert!(work(&app, 0).is_none(), "the active document stays untouched"); + assert!(work(&app, 1).is_some(), "the owner keeps its Work Path"); + // Staying on its own document, the path still commits there. + open_path(&mut app); + pen_commit(&mut app, false); + assert!(work(&app, 0).is_some(), "it commits to the document it was drawn on"); + assert!(work(&app, 1).is_some()); + for act in [0, 1, 2] { + open_path(&mut app); + assert!(app.session.set_active(1)); + match act { + 0 => pen_down(&mut app, 50.0, 50.0, false), + 1 => curvature_down(&mut app, 50.0, 50.0), + _ => assert!(!pen_undo_last_point(&mut app), "nothing of this document's to undo"), + } + let knots = app.ui.pen.as_ref().map_or(0, |p| p.knots.len()); + assert!(knots <= 1, "act {act}: a new path, not the other document's (knots {knots})"); + // The two drawing entry points start a path of their own here; undo has none to take. + let expected = if act == 2 { None } else { app.session.active().map(|st| st.doc.id) }; + assert_eq!(app.ui.pen_doc, expected, "act {act}"); + app.ui.pen = None; + app.ui.pen_doc = None; + assert!(app.session.set_active(0)); + } + open_path(&mut app); + let shapes = |app: &PhotocraftApp| { + let ctx = egui::Context::default(); + let doc = app.session.active().unwrap().doc.clone(); + let xf = ViewXform { + rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), + zoom: 1.0, + center: [100.0, 75.0], + flip: false, + rotation: 0.0, + aspect: 1.0, + }; + let mut out = ctx.run_ui(Default::default(), |ui| draw_overlay(app, ui.painter(), &xf, &doc)); + out.textures_delta.clear(); + out.shapes.len() + }; + // Same view, pen present vs absent: the difference is the path itself, nothing else. + let drawn = |app: &mut PhotocraftApp| { + let with_pen = shapes(app); + let pen = app.ui.pen.take(); + let without = shapes(app); + app.ui.pen = pen; + with_pen - without + }; + assert!(drawn(&mut app) > 0, "its own document draws the path"); + assert!(app.session.set_active(1)); + assert_eq!(drawn(&mut app), 0, "the other document draws nothing of it"); + assert!(app.session.set_active(0)); + app.ui.pen = None; + app.ui.pen_doc = None; + open_path(&mut app); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + app.run("file.new", json!({"width": 120, "height": 90})).unwrap(); + assert_eq!(app.ui.direct_selection, Default::default(), "the anchors went with the path"); + } + + #[test] + fn pen_overlay_uses_the_view_document_even_during_direct_selection() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let owner = app.session.active().unwrap().doc.clone(); + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let other = app.session.active().unwrap().doc.clone(); + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let shapes = |app: &PhotocraftApp, doc: &Document, modifiers| { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }, |ui| { + draw_overlay(app, ui.painter(), &xf, doc) + }); + output.textures_delta.clear(); + output.shapes.len() + }; + for active in [0, 1] { + assert!(app.session.set_active(active)); + for tool in [Tool::Pen, Tool::DirectSelection] { + app.ui.tool = tool; + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::COMMAND] { + for (doc, visible) in [(&owner, true), (&other, false)] { + let with_pen = shapes(&app, doc, modifiers); + let pen = app.ui.pen.take(); + let without = shapes(&app, doc, modifiers); + app.ui.pen = pen; + assert_eq!(with_pen > without, visible, "active {active}, {tool:?}, {modifiers:?}"); + } + } + } + } + } + + #[test] + fn failed_stale_pen_commit_preserves_the_draft_until_retry() { + for action in ["pen", "curvature", "undo", "commit", "command"] { + let mut app = app(); + app.run("path.set", json!({"name": "work", "path": {"subpaths": [{"closed": false, "knots": [[5, 5], [10, 10]]}]}})).unwrap(); + let original = app.session.active().unwrap().doc.work_path.clone().unwrap(); + app.ui.tool = Tool::Pen; + for (x, y) in [(20.0, 20.0), (80.0, 60.0)] { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } + let draft = app.ui.pen.clone(); + let owner = app.ui.pen_doc; + app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.selected_path = Some("foreign named path".into()); + app.session.authorize = Some(|_, _| Err(photocraft_engine::EngineError::Other("test rejection".into()))); + match action { + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => { + assert!(app.run("file.close", json!({})).is_err()); + } + } + assert_eq!(app.ui.pen, draft, "{action}: failed save retains all anchors"); + assert_eq!(app.ui.pen_doc, owner); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&original)); + assert!(app.session.documents()[1].doc.work_path.is_none()); + assert!(app.ui.status_error); + assert!(!crate::direct_select::candidates(&app).iter().any(|(target, _)| *target == crate::direct_select::PathRef::Pen)); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(app.ui.selected_path.as_deref(), Some("foreign named path")); + let saved = app.session.documents()[0].doc.work_path.clone().unwrap(); + assert_eq!(saved.subpaths.len(), original.subpaths.len() + 1); + assert_eq!(saved.subpaths[0], original.subpaths[0]); + app.sync_views(); + assert_eq!(app.session.documents()[0].doc.work_path.as_ref(), Some(&saved), "retry never duplicates the path"); + assert!(app.session.set_active(0)); + assert!(app.session.undo()); + assert_eq!(app.session.active().unwrap().doc.work_path.as_ref(), Some(&original)); + } + } + + #[test] + fn new_document_reports_a_failed_work_path_save_and_retains_the_draft() { + let mut app = app(); + pen_down(&mut app, 20.0, 20.0, false); + pen_up(&mut app); + pen_down(&mut app, 80.0, 60.0, false); + pen_up(&mut app); + let draft = app.ui.pen.clone(); + app.session.authorize = Some(|id, _| if id == "path.set" { Err(photocraft_engine::EngineError::Other("test rejection".into())) } else { Ok(()) }); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.status_error); + assert!(app.ui.status.contains("test rejection")); + assert_eq!(app.ui.pen, draft); + assert_eq!(app.session.active_index(), Some(1)); + assert!(app.session.documents().iter().all(|d| d.doc.work_path.is_none())); + app.session.authorize = None; + app.sync_views(); + assert!(app.ui.pen.is_none()); + assert!(app.session.documents()[0].doc.work_path.is_some()); + assert!(app.session.documents()[1].doc.work_path.is_none()); + } + + #[test] + fn new_document_preserves_a_single_pen_anchor_as_a_work_path() { + for tool in [Tool::Pen, Tool::CurvaturePen] { + let mut app = app(); + app.ui.tool = tool; + if tool == Tool::Pen { + pen_down(&mut app, 20.0, 30.0, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, 20.0, 30.0); + curvature_up(&mut app); + } + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(app.ui.pen.is_none()); + let path = app.session.documents()[0].doc.work_path.as_ref().unwrap(); + assert_eq!(path.subpaths[0].knots.len(), 1); + assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(20.0, 30.0)); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + } + } + + #[test] + fn stale_pen_commits_to_its_owner_across_document_changes() { + use photocraft_doc::{ColorMode, SampleType, Size}; + for tool in [Tool::Pen, Tool::CurvaturePen] { + for action in ["switch", "open", "closeOther", "pen", "curvature", "undo", "commit"] { + let mut app = app(); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = tool; + for (x, y) in [(20.0, 20.0), (80.0, 60.0), (120.0, 30.0)] { + if tool == Tool::Pen { + pen_down(&mut app, x, y, false); + pen_up(&mut app); + } else { + curvature_down(&mut app, x, y); + curvature_up(&mut app); + } + } + let owner = app.ui.pen_doc.unwrap(); + let expected = photocraft_engine::vector_cmds::parse_path(&pen_to_json(app.ui.pen.as_ref().unwrap(), false)).unwrap(); + app.ui.direct_selection.target = Some(crate::direct_select::PathRef::Pen); + app.ui.direct_selection.anchors = vec![[0, 1]]; + assert!(app.session.set_active(0)); + match action { + "open" => { + app.services.import = + Some(Box::new(|name, _, _| Ok((Document::new(name, Size::new(4, 4), ColorMode::Rgb, SampleType::U8), Vec::new())))); + app.open_file("synthetic.png", &[]).unwrap(); + } + "closeOther" => { + app.run("file.close", json!({"document": 0})).unwrap(); + } + "pen" => pen_down(&mut app, 150.0, 150.0, false), + "curvature" => curvature_down(&mut app, 150.0, 150.0), + "undo" => assert!(!pen_undo_last_point(&mut app)), + "commit" => pen_commit(&mut app, true), + _ => app.sync_views(), + } + let source = app.session.documents().iter().find(|d| d.doc.id == owner).unwrap(); + let path = source.doc.work_path.as_ref().expect("the owner keeps the unfinished path"); + assert_eq!(path, &expected, "{tool:?} {action}"); + assert!(app.session.documents().iter().filter(|d| d.doc.id != owner).all(|d| d.doc.work_path.is_none())); + if action != "closeOther" { + assert_ne!(app.session.active().unwrap().doc.id, owner, "the active document is restored"); + } + assert_eq!(app.ui.direct_selection, Default::default()); + let index = app.session.documents().iter().position(|d| d.doc.id == owner).unwrap(); + assert!(app.session.set_active(index)); + assert!(app.session.undo()); + assert!(app.session.active().unwrap().doc.work_path.is_none()); + assert!(app.session.redo()); + assert!(app.session.active().unwrap().doc.work_path.is_some()); + } + } + } + /// #4721: a second Pen path joins the Work Path it was drawn after (still selected), as a new /// subpath, instead of replacing it; a saved path that is selected takes it instead; with no /// path selected, the new path replaces the Work Path, as in Photoshop. @@ -2748,6 +3565,65 @@ mod tests { h.run_steps(2); assert!(h.query_all(egui_kittest::kittest::by().label("Fill:").include_labels()).next().is_none()); } + + /// #6553: a Paths row dragged onto the footer's trash is deleted, whichever row is selected, + /// and a shape layer's path row does not start a drag. + #[test] + fn path_rows_dragged_onto_the_trash_are_deleted() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + let path = json!({"subpaths": [{"closed": true, "knots": [ + {"anchor": [10, 10], "in": [10, 10], "out": [10, 10]}, + {"anchor": [80, 10], "in": [80, 10], "out": [80, 10]}, + {"anchor": [80, 80], "in": [80, 80], "out": [80, 80]} + ]}]}); + app.run("path.set", json!({"name": "Path 1", "path": path})).unwrap(); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + app.ui.selected_path = Some("work".into()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| paths_panel(app, ui), app); + h.run_steps(3); + let trash = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.into_iter().max_by(|a, b| a.center().x.total_cmp(&b.center().x)).unwrap().center() + }; + let drag = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2| { + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + }; + // The saved path, while the work path is selected: only the dragged row goes. + let (row, to) = (h.get_by_label("Path 1").rect().center(), trash(&h)); + drag(&mut h, row, to); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.paths.is_empty(), "the dragged saved path is deleted"); + assert!(doc.work_path.is_some(), "the selected work path is kept"); + // The work path. + let (row, to) = (h.get_by_label("Work Path").rect().center(), trash(&h)); + drag(&mut h, row, to); + assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "the dragged work path is deleted"); + // A drop elsewhere deletes nothing. + h.state_mut().run("path.set", json!({"name": "Path 2", "path": path})).unwrap(); + h.run_steps(2); + let row = h.get_by_label("Path 2").rect().center(); + drag(&mut h, row, row + vec2(0.0, 120.0)); + assert_eq!(h.state().session.active().unwrap().doc.paths.len(), 1, "a drop off the trash keeps the path"); + // A shape layer's path row is not dragged onto the trash (its deletion is #6552's). + h.state_mut().run("shape.create", json!({"kind": "ellipse", "rect": [20, 20, 100, 70]})).unwrap(); + h.run_steps(2); + let layers = h.state().session.active().unwrap().doc.layers.len(); + let (row, to) = (h.get_by_label("Ellipse 1 Shape Path").rect().center(), trash(&h)); + drag(&mut h, row, to); + let doc = &h.state().session.active().unwrap().doc; + assert_eq!((doc.layers.len(), doc.paths.len()), (layers, 1), "nothing is deleted"); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 1cb51a6eab..9fdc456569 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -302,6 +302,9 @@ fn remembered(app: &PhotocraftApp) -> Value { if e.guides != def.guides { m.insert("guides".into(), Value::Bool(e.guides)); } + if e.snap != def.snap { + m.insert("snap".into(), Value::Bool(e.snap)); + } if let Some(show) = changed_flags(v.show, Show::default(), &SHOW_KEYS, show_slot) { m.insert("show".into(), show); } @@ -342,6 +345,9 @@ pub fn restore(app: &mut PhotocraftApp) { if let Some(on) = flag("guides") { app.ui.extras.guides = on; } + if let Some(on) = flag("snap") { + app.ui.extras.snap = on; + } for (k, v) in saved.get("show").and_then(Value::as_object).into_iter().flatten() { if let (Some(slot), Some(on)) = (show_slot(&mut app.ui.view.show, k), v.as_bool()) { *slot = on; @@ -582,11 +588,13 @@ fn wraps(id: &str) -> bool { matches!( id, "file.openAs" + | "file.openExrParts" | "file.saveACopy" | "file.placeEmbedded" | "file.placeLinked" | "layer.smartObjects.replaceContents" | "layer.smartObjects.exportContents" + | "layer.smartObjects.relinkToFile" | "layer.smartObjects.convertToLinked" | "file.closeAll" | "file.closeOthers" @@ -710,6 +718,8 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res k }; o.screen_mode = next.to_string(); + // Tab's view of the chrome (#6760) lasts only while in Full Screen Mode. + app.ui.panels.full_screen_chrome = false; // Both full-screen modes take the whole display; the menu bar stays in the first. let (full_screen, maximized) = ctx.input(|i| (i.viewport().fullscreen.unwrap_or(false), i.viewport().maximized.unwrap_or(false))); let was_maximized = ctx.data(|d| d.get_temp::(was_maximized_id())).unwrap_or(false); @@ -769,6 +779,8 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res let screen = app.session.prefs().units_and_rulers.screen_resolution.max(1.0) as f32; let z = if id == "view.twoHundredPercent" { 2.0 } else { screen / dpi }; app.ui.views[i].zoom = crate::zoom_levels::clamp(z, size); + app.ui.views[i].request_zoom_animation(app.session.prefs().general.animated_zoom); + app.ui.views[i].zoom_anchor = None; Ok(json!({"zoom": app.ui.views[i].zoom})) } "view.fitLayersOnScreen" => fit_layers(app), @@ -800,6 +812,8 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result { v.zoom = crate::zoom_levels::clamp(points * ppp, size); v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0]; v.fit_pending = false; + v.request_zoom_animation(app.session.prefs().general.animated_zoom); + v.zoom_anchor = None; Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } @@ -814,6 +828,10 @@ fn screen_position(v: &Value) -> Option<[f32; 2]> { pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 { let wid = app.ui.alloc_id(); let mut view = app.ui.views.get(doc).cloned().unwrap_or_default(); + view.zoom_animating = false; + view.zoom_anchor = None; + view.zoom_transition = None; + view.display_camera = None; view.fit_pending = offset > 0 || view.fit_pending; app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None }); wid @@ -867,9 +885,14 @@ fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result let src = app.ui.views.get(i).cloned().ok_or("no document")?; let (zoom, loc, rot) = (matches!(k, "matchZoom" | "matchAll"), matches!(k, "matchLocation" | "matchAll"), matches!(k, "matchRotation" | "matchAll")); - let apply = |v: &mut crate::state::View| { + let animated_zoom = app.session.prefs().general.animated_zoom; + // Each view stays within its own document's zoom range (a view not drawn yet has no + // `doc_size`, so the size comes from the document). + let sizes: Vec<[u32; 2]> = app.session.documents().iter().map(|d| [d.doc.size.width, d.doc.size.height]).collect(); + let apply = |v: &mut crate::state::View, doc: usize| { if zoom { - v.zoom = src.zoom; + v.zoom = crate::zoom_levels::clamp(src.zoom, sizes.get(doc).copied().unwrap_or(v.doc_size)); + v.request_zoom_animation(animated_zoom); } if loc { v.center = src.center; @@ -879,8 +902,8 @@ fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result } v.fit_pending = false; }; - app.ui.views.iter_mut().for_each(apply); - app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view)); + app.ui.views.iter_mut().enumerate().for_each(|(d, v)| apply(v, d)); + app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view, w.document)); Ok(json!({"zoom": src.zoom, "center": src.center, "rotation": src.rotation})) } _ => Err(format!("unknown arrangement {k}")), @@ -1073,6 +1096,23 @@ mod form_reset_tests { } } +/// Display order of a `__form` dialog's field keys. `__order` lists them the way Photoshop +/// shows them (e.g. File Info); keys it omits keep their catalogue order after it. +pub fn form_keys(f: &Map) -> Vec { + let mut keys: Vec = f + .get("__order") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|k| !k.starts_with("__") && f.contains_key(*k)) + .map(str::to_string) + .collect(); + let rest: Vec = f.keys().filter(|k| !k.starts_with("__") && !keys.contains(k)).cloned().collect(); + keys.extend(rest); + keys +} + /// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns. pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let choices = f.get("__choices").cloned().unwrap_or(Value::Null); @@ -1080,7 +1120,8 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { // come from `__labels` verbatim; everything else goes through the catalogue. let labels = f.get("__labels").cloned().unwrap_or(Value::Null); let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string); - let keys: Vec = f.keys().filter(|k| !k.starts_with("__")).cloned().collect(); + let keys = form_keys(f); + let mut browse: Option = None; egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); @@ -1127,7 +1168,13 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { crate::widgets::dropdown(ui, &format!("form-{k}"), &mut s, &refs, 200.0); } None => { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0)); + // A folder field gets the system folder picker (#7942). + if k == "dir" && crate::widgets::secondary_button(ui, tl!("Browse…"), 0.0).clicked() { + browse = Some(k.clone()); + } + }); } } json!(s) @@ -1148,6 +1195,9 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { ui.end_row(); } }); + if let Some(key) = browse { + f.insert(crate::dialogs::BROWSE_FIELD.into(), json!(key)); + } camera_data(ui, f); } @@ -1243,6 +1293,18 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) })) } "layer.smartObjects.exportContents" => Some(export_contents(app)), + "layer.smartObjects.relinkToFile" => { + let st = app.session.active()?; + let (doc, layer) = (st.doc.id, st.active_layer?); + // A link needs a file on disk: the desktop picker answers its full path, the web + // picker only a name and the bytes, which can't be linked to. + Some(app.pick_file_bytes(move |app, path, _| { + if !std::path::Path::new(&path).is_absolute() { + return Err(format!("{path}: Relink to File needs a file on disk")); + } + app.with_document(doc, |app| app.run("layer.smartObjects.relinkToFile", json!({"layer": layer.0, "path": path}))) + })) + } "layer.smartObjects.convertToLinked" => { let st = app.session.active()?; let (doc, layer) = (st.doc.id, st.active_layer?); @@ -1282,7 +1344,23 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::>().join("; ")).unwrap_or_default(); dialog( app, - json!({"title": info["title"], "author": info["author"], "authorTitle": info["authorTitle"], "description": info["description"], "keywords": kw, "copyright": info["copyright"], "copyrightStatus": info["copyrightStatus"], "copyrightUrl": info["copyrightUrl"], "__camera": info["camera"]}), + json!({ + "title": info["title"], + "author": info["author"], + "authorTitle": info["authorTitle"], + "description": info["description"], + "keywords": kw, + "copyrightStatus": info["copyrightStatus"], + "copyright": info["copyright"], + "copyrightUrl": info["copyrightUrl"], + "__order": ["title", "author", "authorTitle", "description", "keywords", "copyrightStatus", "copyright", "copyrightUrl"], + "__labels": { + "title": tl!("Document Title"), + "copyright": tl!("Copyright Notice"), + "copyrightUrl": tl!("Copyright Info URL"), + }, + "__camera": info["camera"] + }), json!({"copyrightStatus": ["unknown", "copyrighted", "publicDomain"]}), ) } @@ -1468,11 +1546,14 @@ fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) fn save_a_copy(app: &mut PhotocraftApp) -> Result { let doc = app.session.active().ok_or("no document")?.doc.clone(); let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a); - let suggested = format!("{stem} copy.psd"); + // " copy.psd", or with Preferences › File Handling › Do not append "copy" to filename + // when saving a copy the document's own name (only the extension may change), as in + // Photoshop (#8080). + let suggested = format!("{stem}{}.psd", app.session.prefs().file_handling.copy_suffix()); // The copy is of the document as it was when asked. app.pick_save(&suggested, move |app, path| { - // An RGB Targa asks for its bits per pixel first, and a DDS texture for its compression; - // the copy is written from the prompt. + // An RGB Targa asks for its bits per pixel first, a DDS texture for its compression, and + // a JPEG for its JPEG Options; the copy is written from the prompt. let parked = app.with_document(doc.id, |app| { if crate::targa_options_ui::wants_prompt(app, &path) { crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; @@ -1482,6 +1563,10 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { crate::dds_options_ui::park(app, path.clone(), true, "file.saveACopy")?; return Ok(true); } + if crate::jpeg_options_ui::wants_prompt(app, &path) { + crate::jpeg_options_ui::park(app, path.clone(), true, "file.saveACopy")?; + return Ok(true); + } Ok(false) })?; if parked { @@ -1494,13 +1579,15 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { } /// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's -/// path and saved state are untouched. `choices` are the Targa and DDS answers, when there are any. +/// path and saved state are untouched. `choices` are the Targa, DDS and JPEG answers, when there +/// are any. pub(crate) fn write_copy(app: &mut PhotocraftApp, doc: &photocraft_doc::Document, path: &str, choices: crate::FormatChoices) -> Result { let settings = choices.apply(app.save_settings()); let export = app.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(doc, path, &settings)?; let write = app.services.write.as_mut().ok_or("no writer configured")?; write(path, &bytes)?; + app.detach_others_from_file(path, doc.id); let template = "Saved a copy as {path}"; app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]); app.ui.status_error = false; diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 8496ad076d..05c1f1bb60 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -183,6 +183,28 @@ fn arrange_layouts_floating_windows_and_matching() { assert_eq!(app.ui.windows.len(), 1); } +/// Match Zoom clamps the matched zoom to each document's own range: a 10 x 10 document can't go +/// below the zoom at which its shorter side is 2 px, even when the source view is lower. +#[test] +fn match_zoom_clamps_to_each_documents_zoom_range() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 10, "height": 10})).unwrap(); + app.run("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + app.sync_views(); + let ctx = egui::Context::default(); + assert_eq!(app.session.active_index(), Some(1)); + app.ui.views[1].zoom = 0.05; + menu(&mut app, &ctx, "window.arrange.matchZoom", json!({})).unwrap(); + assert_eq!(app.ui.views[1].zoom, 0.05); + assert_eq!(app.ui.views[0].zoom, crate::zoom_levels::min([10, 10])); + // Floating windows too, before their first frame (no `doc_size` yet). + menu(&mut app, &ctx, "window.arrange.floatAllInWindows", json!({})).unwrap(); + app.ui.views[1].zoom = 0.05; + menu(&mut app, &ctx, "window.arrange.matchZoom", json!({})).unwrap(); + assert_eq!(app.ui.windows.len(), 2); + assert!(app.ui.windows.iter().all(|w| w.view.zoom == if w.document == 0 { crate::zoom_levels::min([10, 10]) } else { 0.05 })); +} + #[test] fn two_up_and_stacked_cells_keep_their_existing_boundaries() { let rect = egui::Rect::from_min_max(egui::pos2(40.0, 75.0), egui::pos2(940.0, 675.0)); @@ -731,3 +753,28 @@ fn every_arranged_tile_has_rulers() { } assert!(labels[0] > 0 && labels[1] > 0, "ruler labels per tile: {labels:?}"); } + +#[test] +fn file_info_dialog_matches_photoshop_field_order_and_labels() { + crate::i18n::with_language(crate::i18n::Lang::EN, || { + let (mut app, ctx) = app_with(1); + let d = menu(&mut app, &ctx, "file.fileInfo", json!({})).unwrap()["dialog"].as_u64().unwrap(); + let fields = app.ui.dialog_mut(d).unwrap().fields.clone(); + assert_eq!( + form_keys(&fields).iter().map(String::as_str).collect::>(), + ["title", "author", "authorTitle", "description", "keywords", "copyrightStatus", "copyright", "copyrightUrl"] + ); + let labels = fields["__labels"].as_object().unwrap(); + assert_eq!(labels["title"], "Document Title"); + assert_eq!(labels["copyright"], "Copyright Notice"); + assert_eq!(labels["copyrightUrl"], "Copyright Info URL"); + }); +} + +#[test] +fn form_keys_orders_by_order_then_catalogue() { + let f = json!({"zeta": "", "alpha": "", "mid": "", "__order": ["mid", "zeta", "ghost"]}).as_object().unwrap().clone(); + assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["mid", "zeta", "alpha"]); + let f = json!({"zeta": "", "alpha": ""}).as_object().unwrap().clone(); + assert_eq!(form_keys(&f).iter().map(String::as_str).collect::>(), ["alpha", "zeta"]); +} diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs index bd0353d678..3034c1c5f2 100644 --- a/crates/ui-egui/src/vp_ui.rs +++ b/crates/ui-egui/src/vp_ui.rs @@ -438,18 +438,24 @@ pub fn escape(app: &mut PhotocraftApp) { } } -pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { +/// The paste on its plane: image content, drawn under the grid and guides like the document so a +/// guide stays visible across it (#7781). The planes and their grids are [`draw`]. +pub fn draw_content(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); - let tokens = crate::theme::Tokens::get(painter.ctx()); - let center = s.center(); - // The paste first, under the grids. if let Some(i) = s.paste_plane() - && s.planes.get(i).is_some_and(|p| p.check(center) != PlaneCheck::Invalid) + && s.planes.get(i).is_some_and(|p| p.check(s.center()) != PlaneCheck::Invalid) && let (Some((h, lu, lv)), Some(c)) = (s.frame(i), s.clip.as_ref()) && let Some(ext) = s.paste_extent(lu, lv) { draw_paste(painter, &scr, &h, s.paste_at, ext, &c.texture); } +} + +/// The planes, their grids and nodes over the paste ([`draw_content`]) and the guides. +pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { + let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); + let tokens = crate::theme::Tokens::get(painter.ctx()); + let center = s.center(); for (i, p) in s.planes.iter().enumerate() { let check = p.check(center); let colour = match check { diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 68a2321c44..ec7047580f 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -23,10 +23,11 @@ //! The target behaviour is exact ×1.1 steps, one per notch, with no easing in between //! (measured frame by frame on a screen recording). egui smooths a wheel notch over several //! frames, so zooming reads the wheel events themselves and ignores that smoothed tail; panning -//! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next -//! wheel event: releasing Alt while the tail is still settling must not turn it into a pan. +//! keeps the smoothed delta. The Alt state is taken from the wheel events (and the Alt key's own +//! press and release, `wheel_events`) and kept until the next wheel event: releasing Alt while +//! the tail is still settling must not turn it into a pan. -use egui::{Context, Event, Id, InputState, Modifiers, MouseWheelUnit, RawInput, Vec2}; +use egui::{Context, Event, Id, InputState, Key, Modifiers, MouseWheelUnit, RawInput, Vec2}; /// Zoom factor of one wheel notch: every step is exactly ×1.1. pub const NOTCH: f32 = 1.1; @@ -214,22 +215,9 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: // --- Modifier tracking (always runs for all callers) --- let (scroll, zoom_delta, latest_alt, latest_shift, wheel, page) = ctx.input(|i| { - let latest_alt = i.events.iter().rev().find_map(|e| match e { - Event::MouseWheel { modifiers, .. } => Some(modifiers.alt && !modifiers.command && !modifiers.ctrl), - _ => None, - }); - let latest_shift = i.events.iter().rev().find_map(|e| match e { - Event::MouseWheel { modifiers, .. } => Some(modifiers.shift && !modifiers.command && !modifiers.ctrl), - _ => None, - }); - let wheel: Vec<(MouseWheelUnit, Vec2, Modifiers)> = i - .events - .iter() - .filter_map(|e| match e { - Event::MouseWheel { unit, delta, modifiers, .. } => Some((*unit, *delta, *modifiers)), - _ => None, - }) - .collect(); + let wheel = wheel_events(i); + let latest_alt = wheel.last().map(|(_, _, m)| m.alt && !m.command && !m.ctrl); + let latest_shift = wheel.last().map(|(_, _, m)| m.shift && !m.command && !m.ctrl); (i.smooth_scroll_delta, pinch_factor(i, touch_gestures), latest_alt, latest_shift, wheel, i.content_rect().height()) }); let alt = match latest_alt { @@ -334,6 +322,37 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: } } +fn is_alt_key(key: Key) -> bool { + matches!(key, Key::AltLeft | Key::AltRight) +} + +/// This frame's wheel events, in order, with ⌥/Alt added to their modifiers while an Alt key is +/// down (#8049). The modifiers a wheel event carries are the windowing layer's modifier state, +/// which on Windows can miss a held Alt (the wheel then scrolled until another modifier was +/// tapped), while the Alt key's own press and release still arrive; the menu bar follows those +/// too (`menu_keys`). Alt is down from its press until its release, starting from whether it +/// was down before this frame's first Alt key event (egui's `keys_down` when there is none). +fn wheel_events(i: &InputState) -> Vec<(MouseWheelUnit, Vec2, Modifiers)> { + let first_alt_key = i.events.iter().find_map(|e| match e { + // A repeat means the key was already down; a release means it was down until then. + Event::Key { key, pressed, repeat, .. } if is_alt_key(*key) => Some(!*pressed || *repeat), + _ => None, + }); + let mut alt_key = first_alt_key.unwrap_or_else(|| i.key_down(Key::AltLeft) || i.key_down(Key::AltRight)); + let mut wheel = Vec::new(); + for e in &i.events { + match e { + Event::Key { key, pressed, .. } if is_alt_key(*key) => alt_key = *pressed, + Event::MouseWheel { unit, delta, modifiers, .. } => { + let modifiers = Modifiers { alt: modifiers.alt || alt_key, ..*modifiers }; + wheel.push((*unit, *delta, modifiers)); + } + _ => {} + } + } + wheel +} + /// This frame's pinch zoom factor (1 = none). While two fingers are down on a touch screen, egui's /// `zoom_delta` reports their pinch in place of the trackpad's `Event::Zoom`. With Preferences › /// Enhanced Controls › Enable Gestures off (`touch_gestures` false) the fingers are ignored and only diff --git a/crates/ui-egui/src/wide_angle_ui.rs b/crates/ui-egui/src/wide_angle_ui.rs index 35b996deea..b577bf5861 100644 --- a/crates/ui-egui/src/wide_angle_ui.rs +++ b/crates/ui-egui/src/wide_angle_ui.rs @@ -1,12 +1,13 @@ //! Filter › Adaptive Wide Angle…: a full-window dialog like Photoshop's. Drag on the image to //! draw a constraint (the scene line between the two points, curved by the lens model); hold -//! Shift when releasing to make it horizontal or vertical; right-click a constraint to delete -//! it. The right panel holds the correction model, focal length, crop factor, scale and the +//! Shift when releasing to make it horizontal or vertical; right-click a constraint for its menu +//! (Free, Horizontal, Vertical, Delete), as in Photoshop. The right panel holds the correction model, focal length, crop factor, scale and the //! constraint list. The preview solves the mesh warp on a CPU proxy (≤ 800 px); OK runs //! `filter.adaptiveWideAngle` with the constraints in document pixels. //! //! Control channel: `ui.menu.invoke {"id":"filter.adaptiveWideAngle","params":{"ui":{"set":{…}, -//! "add":{"a":[x,y],"b":[x,y],"orientation":"…"}, "remove":i, "commit":true | "cancel":true}}}`. +//! "add":{"a":[x,y],"b":[x,y],"orientation":"…"}, "orient":{"index":i,"orientation":"…"}, "remove":i, +//! "commit":true | "cancel":true}}}`. use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::transform::Interp; @@ -40,6 +41,8 @@ pub struct WideAngleDialog { dirty: bool, pub preview: bool, drag: Option<([f64; 2], [f64; 2])>, + /// The constraint a right-click opened the constraint menu on. + menu_for: Option, pub residual: f64, pub render_ms: f64, history: EditHistory, @@ -143,6 +146,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> dirty: true, preview: true, drag: None, + menu_for: None, residual: 0.0, render_ms: 0.0, }; @@ -192,6 +196,12 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val d.params.constraints.push(Constraint { a: pa, b: pb, orientation: parse_orientation(a.get("orientation").and_then(Value::as_str).unwrap_or("free")) }); d.dirty = true; } + if let Some(o) = ui.get("orient") { + let i = o.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let Some(c) = i.and_then(|i| d.params.constraints.get_mut(i)) else { return Some(Err("`orient` needs the index of a constraint".into())) }; + c.orientation = parse_orientation(o.get("orientation").and_then(Value::as_str).unwrap_or("free")); + d.dirty = true; + } if let Some(i) = ui.get("remove").and_then(Value::as_u64) && (i as usize) < d.params.constraints.len() { @@ -217,6 +227,32 @@ fn commit(app: &mut PhotocraftApp) -> Result { app.run("filter.adaptiveWideAngle", d.command_params()) } +/// Photoshop's right-click menu on a constraint: its orientation (the current one checked) and +/// Delete. A right-click away from every constraint opens nothing. +fn constraint_menu(ui: &mut egui::Ui, d: &mut WideAngleDialog) { + let Some(i) = d.menu_for.filter(|i| *i < d.params.constraints.len()) else { + ui.close(); + return; + }; + for (o, label) in [(Orientation::Free, tl!("Free")), (Orientation::Horizontal, tl!("Horizontal")), (Orientation::Vertical, tl!("Vertical"))] { + let current = d.params.constraints.get(i).is_some_and(|c| c.orientation == o); + if ui.radio(current, label).clicked() { + if let Some(c) = d.params.constraints.get_mut(i) { + c.orientation = o; + d.dirty = true; + } + ui.close(); + } + } + ui.separator(); + if ui.button(tl!("Delete")).clicked() { + d.params.constraints.remove(i); + d.menu_for = None; + d.dirty = true; + ui.close(); + } +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.wide_angle.is_none() { return; @@ -274,7 +310,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() { - // Delete the constraint whose curve passes nearest the click. + // The constraint whose curve passes nearest the click gets the menu. let near = d .params .constraints @@ -282,13 +318,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { .enumerate() .map(|(i, c)| (i, cam.arc(c.a, c.b, 24).iter().map(|q| (to_screen(*q) - p).length()).fold(f32::MAX, f32::min))) .min_by(|a, b| a.1.total_cmp(&b.1)); - if let Some((i, dist)) = near - && dist < 10.0 - { - d.params.constraints.remove(i); - d.dirty = true; - } + d.menu_for = near.filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i); } + resp.context_menu(|ui| constraint_menu(ui, d)); let show_src = !d.preview || d.drag.is_some(); let tex = if show_src { d.src_tex.as_ref() } else { d.out_tex.as_ref() }; widgets::checker(&painter, img_r, 8.0); @@ -393,9 +425,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { widgets::hairline(ui); widgets::checkbox(ui, &mut d.preview, tl!("Preview")); ui.label( - egui::RichText::new(tl!("Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete.")) - .color(t.text_faint) - .size(11.0), + egui::RichText::new(tl!( + "Drag on the image to add a constraint; Shift for horizontal/vertical; right-click a constraint to change or delete it." + )) + .color(t.text_faint) + .size(11.0), ); }); let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max); @@ -458,6 +492,74 @@ mod tests { assert!(app.wide_angle.is_none()); } + /// A 160 × 100 grey layer to open the dialog on. + fn grey_layer() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 160, "height": 100})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#808080"})).unwrap(); + app + } + + /// Open the dialog with one free constraint across the middle of the layer. + fn open_with_a_constraint(app: &mut PhotocraftApp, ctx: &egui::Context) { + menu(app, ctx, "filter.adaptiveWideAngle", &json!({"ui": {"add": {"a": [20, 50], "b": [140, 50]}}})).unwrap().unwrap(); + } + + /// `orient` sets a constraint's orientation, as the right-click menu does; a bad index is + /// an error that changes nothing. + #[test] + fn orient_sets_a_constraint_orientation() { + let mut app = grey_layer(); + let ctx = egui::Context::default(); + open_with_a_constraint(&mut app, &ctx); + let r = menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"orient": {"index": 0, "orientation": "vertical"}}})).unwrap().unwrap(); + assert_eq!(r["params"]["constraints"][0]["orientation"], "vertical"); + for bad in [json!({"index": 1, "orientation": "horizontal"}), json!({"orientation": "horizontal"}), json!({"index": -1})] { + assert!(menu(&mut app, &ctx, "filter.adaptiveWideAngle", &json!({"ui": {"orient": bad}})).unwrap().is_err(), "{bad}"); + } + assert_eq!(app.wide_angle.as_ref().unwrap().params.constraints[0].orientation, Orientation::Vertical); + } + + /// Right-clicking a constraint opens Photoshop's constraint menu: an orientation (the + /// current one checked) or Delete. A right-click away from every constraint deletes nothing + /// and opens no menu (it used to delete the nearest constraint outright). + #[test] + fn right_click_on_a_constraint_opens_its_menu() { + use egui_kittest::kittest::Queryable; + let size = vec2(1000.0, 600.0); + let mut h = egui_kittest::Harness::builder().with_size(size).build_ui_state(|ui, app: &mut PhotocraftApp| show(app, ui.ctx()), grey_layer()); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let ctx = h.ctx.clone(); + open_with_a_constraint(h.state_mut(), &ctx); + h.run_steps(2); + // The image view (see `show`): the window less the title, footer and panel, inset 16 pt. + let view = ERect::from_min_max(pos2(16.0, 30.0 + 16.0), pos2(size.x - PANEL_W - 16.0, size.y - 48.0 - 16.0)); + let right_click = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>, p: Pos2| { + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.extend([egui::Event::PointerMoved(p), button(true)]); + h.run_steps(1); + h.input_mut().events.push(button(false)); + h.run_steps(2); + }; + let constraints = |h: &egui_kittest::Harness<'_, PhotocraftApp>| h.state().wide_angle.as_ref().unwrap().params.constraints.clone(); + // The constraint runs through the document's centre, which the view centres. + right_click(&mut h, view.center()); + h.get_by_label("Horizontal").click(); + h.run_steps(2); + assert_eq!(constraints(&h).len(), 1, "choosing an orientation keeps the constraint"); + assert_eq!(constraints(&h)[0].orientation, Orientation::Horizontal); + // Away from it: no menu, nothing deleted. + right_click(&mut h, view.left_top() + vec2(4.0, 4.0)); + assert!(h.query_by_label("Horizontal").is_none(), "no menu away from a constraint"); + assert_eq!(constraints(&h).len(), 1); + // Delete from the menu (the panel's list has its own Delete button too). + right_click(&mut h, view.center()); + h.get_all_by_label("Delete").last().expect("the menu's Delete").click(); + h.run_steps(2); + assert!(constraints(&h).is_empty()); + } + #[test] fn command_z_steps_the_dialog_not_the_document() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0a91058387..d7ab90b2e9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -183,6 +183,9 @@ pub fn panel_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { ui.painter().line_segment([pos2(r.left() - m.left as f32, y), pos2(r.right() + m.right as f32, y)], Stroke::new(1.0, t.separator)); let bar = Rect::from_min_size(pos2(r.left(), r.top() + 1.0), vec2(r.width(), FOOTER_BAR)); let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + // The bar runs on over the body's bottom margin, past where the body's own clip ends: without + // this its buttons were cut off there, a 26 px hover highlight drawn about 21 px tall. + bar_ui.set_clip_rect(bar_ui.clip_rect().union(bar)); bar_ui.spacing_mut().item_spacing.x = 2.0; add(&mut bar_ui) } @@ -405,6 +408,8 @@ struct LabelScrub { max_gap: f32, /// Options captions defer interaction until the adjacent field bounds are known. option_area: bool, + /// An options icon's tooltip, shown by the drag area laid over it (which takes its hover). + tip: Option<&'static str>, /// Whole steps to move the value (left negative; Shift ×10, Alt a tenth as fast). steps: f32, started: bool, @@ -426,12 +431,23 @@ pub fn scrub_label(ui: &Ui, label: &Response) -> Response { /// A tool-options caption whose scrub target includes the adjacent field's outer box. /// Keep the original label bounds for linking; register the hit area before the numeric editor. pub fn scrub_option_label(ui: &Ui, label: &Response) { + scrub_option_area(ui, label, None); +} + +/// [`scrub_option_label`] for an icon with tooltip `tip`. The field's drag area is laid over the +/// icon after it, so egui hovers the area and not the icon: `on_hover_text` on the icon never shows. +pub fn scrub_option_icon(ui: &Ui, icon: &Response, tip: &'static str) { + scrub_option_area(ui, icon, Some(tip)); +} + +fn scrub_option_area(ui: &Ui, label: &Response, tip: Option<&'static str>) { let scrub = LabelScrub { pass: ui.ctx().cumulative_pass_nr(), label: label.id.with("pc-option-scrub"), rect: label.rect, max_gap: 24.0, option_area: true, + tip, steps: 0.0, started: false, dragging: false, @@ -487,6 +503,7 @@ fn label_scrub_response(ui: &Ui, resp: &Response, original_rect: Rect, max_gap: rect: original_rect, max_gap, option_area: false, + tip: None, steps, started: resp.drag_started(), dragging: resp.dragged(), @@ -516,6 +533,8 @@ fn take_label_scrub(ui: &Ui, rect: Rect) -> Option { struct FieldOpts { /// Decimal places shown (default: one, or two for a small range). decimals: Option, + /// Arrow-key and scrub step follow `decimals` (adjustment rows via [`slider_row_prec`], #5914). + step_from_decimals: bool, /// The unit typed units convert into (default: the suffix's, at [`set_field_resolution`]). units: Option, /// The length (px) 100 % stands for, so lengths and percentages convert into each other. @@ -523,6 +542,19 @@ struct FieldOpts { wrap_drag: bool, } +/// Arrow-key and drag step for a value field: [`FieldOpts::step_from_decimals`] uses `decimals`; +/// otherwise the range width picks 0.01 (≤10) or 1.0. +fn field_step(range: &std::ops::RangeInclusive, decimals: Option, step_from_decimals: bool) -> f32 { + if step_from_decimals && let Some(d) = decimals { + return 10f32.powi(-(d.min(6) as i32)); + } + if *range.end() - *range.start() <= 10.0 { 0.01 } else { 1.0 } +} + +fn field_fine(range: &std::ops::RangeInclusive, decimals: Option, step_from_decimals: bool) -> bool { + if step_from_decimals { decimals.is_some_and(|d| d > 0) || *range.end() - *range.start() <= 10.0 } else { *range.end() - *range.start() <= 10.0 } +} + /// A [`value_field`] that leaves `trailing` points free inside its box, right of the suffix, and /// returns the box with the number's response. fn value_field_in( @@ -547,7 +579,7 @@ fn value_field_box( trailing: f32, opts: FieldOpts, ) -> (Response, Rect) { - let FieldOpts { decimals, units, extent, wrap_drag } = opts; + let FieldOpts { decimals, step_from_decimals, units, extent, wrap_drag } = opts; let t = Tokens::get(ui.ctx()); let (rect, slot) = ui.allocate_exact_size(size, Sense::hover()); let scrub = take_label_scrub(ui, rect).map(|s| { @@ -558,7 +590,12 @@ fn value_field_box( // numeric button. It is not focusable; the editor drawn next wins inside its bounds. // Preserve a popup field's trailing arrow, and never extend outside the UI clip. let hit = Rect::from_min_max(pos2(s.rect.left(), rect.top()), pos2(rect.right() - trailing, rect.bottom())).intersect(ui.clip_rect()); - let resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + let mut resp = ui.interact(hit, s.label, Sense::CLICK | Sense::DRAG); + if let Some(tip) = s.tip + && resp.hover_pos().is_some_and(|p| s.rect.contains(p)) + { + resp = resp.on_hover_text(tip); + } label_scrub_response(ui, &resp, s.rect, s.max_gap) }); let (dpi, ppi) = ui.data(|d| d.get_temp::<(f64, f64)>(field_resolution_id())).unwrap_or((72.0, 72.0)); @@ -585,9 +622,22 @@ fn value_field_box( let cy = rect.center().y; let field = Rect::from_min_max(pos2(rect.left() + 4.0, cy - slot_h / 2.0), pos2(rect.right() - 4.0 - suffix_w - trailing, cy + slot_h / 2.0)); // Small ranges (gamma 0.01–9.99, 0–1 centres) need two decimals and a finer drag, like Photoshop. - let fine = range.end() - range.start() <= 10.0; + let fine = field_fine(&range, decimals, step_from_decimals); + let unit = field_step(&range, decimals, step_from_decimals); let (lo, hi) = (*range.start(), *range.end()); - let (step, grid) = arrow_step(ui, slot.id, if fine { 0.01 } else { 1.0 }); + // The field's id is only known once it's drawn: last frame's, saved under `slot` below. + let focused = ui.data(|d| d.get_temp::(slot.id)).filter(|id| ui.memory(|m| m.has_focus(*id))); + let (step, grid) = arrow_step(ui, slot.id, unit); + // The wheel steps it as the arrow keys do (#5473), focused or under the pointer. + let notches = crate::field_wheel::notches(ui, slot.id, focused.is_some(), rect); + if notches != 0.0 + && let Some(id) = focused + && ui.data(|d| d.get_temp(id.with("arithmetic"))).unwrap_or(false) + { + // A typed sum works itself out first, as before an arrow step. + ui.memory_mut(|m| m.surrender_focus(id)); + } + let step = step + notches * unit; // new_child (not scope_builder): a scope would move the parent cursor back to the child rect. let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field)); let mut resp = { @@ -617,7 +667,6 @@ fn value_field_box( ui.memory_mut(|m| m.surrender_focus(resp.id)); } if s.steps != 0.0 { - let unit = if fine { 0.01 } else { 1.0 }; // Onto the step grid first, so a whole-number field drops its decimals (55.4 + 1 = 56). let v = ((*value / unit).round() + s.steps) * unit; let v = (v * 1e4).round() / 1e4; @@ -643,16 +692,26 @@ fn value_field_box( // Round to 4 decimal places, so repeated 0.1 steps don't leave float errors. let v = (v * 1e4).round() / 1e4; // `f32::clamp` panics on a reversed or NaN range, which `DragValue::range` accepts. - *value = if lo <= hi { v.clamp(lo, hi) } else { v }; - // Only take focus back after a typed sum gave it up: re-requesting it while held resets - // the field's arrow-key filter next frame, and a held arrow then moves focus away (#5474). - if !ui.memory(|m| m.has_focus(resp.id)) { - ui.memory_mut(|m| m.request_focus(resp.id)); + let v = if lo <= hi { v.clamp(lo, hi) } else { v }; + // A wild wheel delta must not leave infinity in the field. + if v.is_finite() { + *value = v; + } + // A wheel step over a field without focus leaves the focus where it is. + if focused.is_some() { + // Only take focus back after a typed sum gave it up: re-requesting it while held resets + // the field's arrow-key filter next frame, and a held arrow then moves focus away (#5474). + if !ui.memory(|m| m.has_focus(resp.id)) { + ui.memory_mut(|m| m.request_focus(resp.id)); + } + // The text stays selected, as in Photoshop: typing after a step replaces the value. + crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); } - // The text stays selected, as in Photoshop: typing after a step replaces the value. - crate::field_tab::select_all(ui.ctx(), resp.id, &if fine { fmt_num2(f64::from(*value)) } else { fmt_num(f64::from(*value)) }); resp.mark_changed(); } + if ui.memory(|m| m.has_focus(resp.id)) { + crate::field_wheel::set_focused(ui.ctx(), resp.id, slot.id); + } (resp, rect) } @@ -737,7 +796,9 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o ui.painter().rect_filled(arrow, t.radius_sm, t.field_border.gamma_multiply(0.6)); } chevron_icon(ui, arrow, ui.style().interact(&resp), open); + let parent_layer = resp.layer_id; popup.show(|ui| { + crate::press_menu::above_parent_ui(parent_layer, ui); ui.set_width(POPUP_SLIDER_W); let s = slider(ui, value, range, None); s.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(*value), &name)); @@ -989,11 +1050,6 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch if !hovered { return 0.0; } - let acc_id = id.with("wheel-acc"); - let mut acc: f32 = ui.data(|d| d.get_temp(acc_id)).unwrap_or(0.0); - let per_line = ui.ctx().options(|o| o.input_options.line_scroll_speed); - let per_line = if per_line.is_finite() && per_line > 0.0 { per_line } else { 40.0 }; - let mut notches = 0.0; let wheel: Vec<(egui::MouseWheelUnit, Vec2, bool)> = ui.input(|i| { i.events .iter() @@ -1003,7 +1059,17 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch }) .collect() }); - for (unit, delta, shift) in wheel { + count_notches(ui, id.with("wheel-acc"), &wheel, shift_notches) +} + +/// Whole notches in `wheel` (unit, delta, Shift held), a notch with Shift counting +/// `shift_notches`; fractions carry over in egui memory under `acc_id`. +pub(crate) fn count_notches(ui: &Ui, acc_id: egui::Id, wheel: &[(egui::MouseWheelUnit, Vec2, bool)], shift_notches: f32) -> f32 { + let mut acc: f32 = ui.data(|d| d.get_temp(acc_id)).unwrap_or(0.0); + let per_line = ui.ctx().options(|o| o.input_options.line_scroll_speed); + let per_line = if per_line.is_finite() && per_line > 0.0 { per_line } else { 40.0 }; + let mut notches = 0.0; + for &(unit, delta, shift) in wheel { // Some systems turn Shift+wheel into a horizontal scroll. let d = if delta.y != 0.0 { delta.y } else { delta.x }; acc += match unit { @@ -1024,26 +1090,39 @@ pub(crate) fn wheel_notches_of(ui: &Ui, id: egui::Id, hovered: bool, shift_notch /// Windows' default double-click time (Photoshop's; egui's own default is 0.3 s). const DOUBLE_CLICK_S: f64 = 0.5; -/// Whether this click on `resp` completes a double-click, counted the Windows way (Photoshop): -/// a click soon after a lone click at the same spot; the click after a double-click starts over. -/// (egui would call a third quick click a triple-click even when the first two weren't a double.) -fn second_click(ui: &Ui, resp: &Response) -> bool { +/// Finger jitter between taps easily exceeds egui's 6 px `max_click_dist`; 64 px is about two-thirds of an inch at 96 DPI (roughly a fingertip pad). 32 px still rejected casual Framework double-taps; Windows' mouse SM_CXDOUBLECLK is only 4 px and is not used for touch. +const TOUCH_MAX_CLICK_DIST: f32 = 64.0; + +/// True when this frame's pointer work came from a touch contact (still down, or a Touch event including End on the click frame). +fn pointer_from_touch(ui: &Ui) -> bool { + ui.input(|i| i.any_touches() || i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. }))) +} + +/// Whether this click on `resp` completes a double-click / double-tap, counted the Windows way (Photoshop): a click soon after a lone click near the same spot; the click after a double starts over. +/// +/// Time window is 0.5 s (Windows / Photoshop; egui's default is 0.3 s). Mouse uses egui's `max_click_dist` (6 px); touch uses [`TOUCH_MAX_CLICK_DIST`] so finger jitter between taps still counts. Call on every click for that widget so the first tap is remembered. +pub fn second_click(ui: &Ui, resp: &Response) -> bool { if !resp.clicked() { return false; } let key = resp.id.with("pc-last-click"); - let (time, pos) = ui.input(|i| (i.time, i.pointer.interact_pos())); - let dist = ui.ctx().options(|o| o.input_options.max_click_dist); - let last: Option<(f64, Pos2)> = ui.data(|d| d.get_temp(key)); + // Prefer the response's hit pos: raw `pointer.interact_pos` can be unset on a release-only frame. + let pos = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())); + let time = ui.input(|i| i.time); + let touch = pointer_from_touch(ui); + let mouse_dist = ui.ctx().options(|o| o.input_options.max_click_dist); + let last: Option<(f64, Pos2, bool)> = ui.data(|d| d.get_temp(key)); + // Looser distance if either tap in the pair was touch (End clears `any_touches` but leaves Touch in `events`). + let dist = if touch || last.is_some_and(|(_, _, t)| t) { TOUCH_MAX_CLICK_DIST.max(mouse_dist) } else { mouse_dist }; let double = match (last, pos) { - (Some((t, p)), Some(q)) => time - t < DOUBLE_CLICK_S && p.distance(q) < dist, + (Some((t, p, _)), Some(q)) => time - t < DOUBLE_CLICK_S && p.distance(q) < dist, _ => false, }; ui.data_mut(|d| { if let (false, Some(q)) = (double, pos) { - d.insert_temp(key, (time, q)); + d.insert_temp(key, (time, q, touch)); } else { - d.remove::<(f64, Pos2)>(key); + d.remove::<(f64, Pos2, bool)>(key); } }); double @@ -1116,8 +1195,8 @@ fn slider_with(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive Color32 { + let [r, g, b] = photocraft_color::convert::hsb_to_rgb([deg, 1.0, 1.0]); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(r), q(g), q(b)) +} + +/// Hue spectrum stops for colour sliders: red, yellow, green, cyan, blue, magenta, red. pub fn hue_stops() -> Vec { - (0..=12) - .map(|i| { - let h = i as f32 / 12.0; - let rgb = egui::ecolor::Hsva::new(h, 0.85, 1.0, 1.0).to_srgb(); - Color32::from_rgb(rgb[0], rgb[1], rgb[2]) - }) - .collect() + (0..=12).map(|i| hue_color(i as f32 * 30.0)).collect() } /// A compact labelled dropdown in the studio style. @@ -1663,6 +1755,7 @@ pub fn swatch_popup(swatch: &Response) -> egui::Popup<'static> { ctx.data_mut(|d| d.remove::(key)); return popup; } + crate::press_menu::above_parent(ctx, swatch.layer_id, egui::LayerId::new(egui::Order::Foreground, popup.get_id())); // Its width is known from the frame after it opened (egui sizes it unseen first). let left = ctx.data(|d| d.get_temp::(key)).or_else(|| { let width = popup.get_expected_size()?.x; @@ -1761,10 +1854,10 @@ fn checker_tile(ctx: &egui::Context) -> egui::TextureHandle { /// Spectrum-style checkbox (blue when checked). pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { let t = Tokens::get(ui.ctx()); - let mut resp = ui + let (mut resp, label_resp) = ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::CLICK); let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); let l = ui .add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).selectable(false).sense(Sense::click())) @@ -1772,13 +1865,16 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { // Pressing the label presses the box (#4476). let pressed = resp.is_pointer_button_down_on() || l.is_pointer_button_down_on(); check_box(ui.painter(), rect, *on, resp.hovered() || l.hovered(), pressed, &t); - resp.union(l) + (resp.union(l.clone()), l) }) .inner; if resp.clicked() { *on = !*on; resp.mark_changed(); } + // The label is the single named, focusable checkbox; the box is a mouse target. + // Override Label's metadata instead of creating a second node with the same name. + label_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, tl!(label))); resp } @@ -1865,12 +1961,55 @@ fn clean(text: &str) -> String { #[cfg(test)] mod tests { - use egui::{Key, Modifiers}; + use super::{hue_color, hue_stops}; + use egui::{Color32, Key, Modifiers}; + + #[test] + fn hue_stops_are_pure_and_evenly_spaced() { + let c = |r, g, b| Color32::from_rgb(r, g, b); + let want = [c(255, 0, 0), c(255, 255, 0), c(0, 255, 0), c(0, 255, 255), c(0, 0, 255), c(255, 0, 255), c(255, 0, 0)]; + let stops = hue_stops(); + assert_eq!(stops.len(), 13); + for (k, w) in want.iter().enumerate() { + assert_eq!(stops[k * 2], *w, "stop at {} degrees", k * 60); + assert_eq!(hue_color(k as f32 * 60.0), *w); + } + } + + /// A light theme rings the slider knob with its outline token; a dark Studio-layout one draws + /// the plain knob (#7927). + #[test] + fn light_themes_draw_the_slider_knob_outline() { + use crate::theme::{ThemeKind, Tokens}; + fn rings(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(v) => v.iter().for_each(|s| rings(s, out)), + egui::Shape::Circle(c) if c.radius == 7.0 && c.stroke.width == 1.0 => out.push(c.stroke.color), + _ => {} + } + } + for kind in [ThemeKind::StudioLight, ThemeKind::Adwaita, ThemeKind::BreezeLight, ThemeKind::SoftLight, ThemeKind::Studio] { + let ctx = egui::Context::default(); + crate::theme::apply(&ctx, kind); + let mut v = 50.0; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + super::slider(ui, &mut v, 0.0..=100.0, None); + }); + out.textures_delta.clear(); + let mut found = Vec::new(); + out.shapes.iter().for_each(|c| rings(&c.shape, &mut found)); + let want: Vec<_> = Tokens::for_kind(kind).knob_outline().into_iter().collect(); + assert_eq!(found, want, "{kind:?}"); + } + } #[test] fn checkbox_label_is_a_button_target_not_selectable_text() { use egui::CursorIcon; - use egui_kittest::{Harness, kittest::Queryable}; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; let mut h = Harness::builder().with_size(egui::vec2(300.0, 80.0)).build_ui_state( |ui, on: &mut bool| { @@ -1886,6 +2025,13 @@ mod tests { h.get_by_label("Show Transform Controls").click(); h.run_steps(2); assert!(*h.state()); + let checkbox = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Show Transform Controls"); + assert_eq!(checkbox.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + checkbox.focus(); + h.run_steps(1); + h.key_press(egui::Key::Space); + h.run_steps(2); + assert!(!*h.state(), "the same named checkbox supports Space"); } /// #3208: a rounded swatch's checker is one shared, repeating, unblurred two-by-two tile on @@ -1951,8 +2097,109 @@ mod tests { out.textures_delta.clear(); } } + + /// One primary press+release at `pos`. When `touch`, emit Touch Start/End with the pointer events (as egui-winit does) so [`super::second_click`] uses the touch distance. + fn pointer_click(ctx: &egui::Context, time: f64, pos: egui::Pos2, touch: bool) -> bool { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let touch_ev = |phase| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None }; + let mut saw = false; + for (t, mut events) in [ + (time, { + let mut v = vec![egui::Event::PointerMoved(pos), button(true)]; + if touch { + v.insert(0, touch_ev(egui::TouchPhase::Start)); + } + v + }), + (time + 0.02, { + let mut v = vec![button(false)]; + if touch { + v.insert(0, touch_ev(egui::TouchPhase::End)); + } + v + }), + ] { + let mut raw = egui::RawInput { time: Some(t), screen_rect: Some(area), ..Default::default() }; + raw.events.append(&mut events); + let mut out = ctx.run_ui(raw, |ui| { + let r = ui.allocate_rect(area, egui::Sense::click()); + saw |= super::second_click(ui, &r); + }); + out.textures_delta.clear(); + } + saw + } + + /// #2274 touch: taps past egui's 6 px mouse limit (and past the old 32 px touch slop) within 0.5 s still count as a double; the same mouse path does not. + #[test] + fn second_click_tolerates_touch_jitter_but_keeps_mouse_tight() { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let a = egui::pos2(50.0, 50.0); + let mild = egui::pos2(70.0, 50.0); // 20 px — past mouse max_click_dist (6) + let wide = egui::pos2(50.0 + 48.0, 50.0); // past the old 32 px slop, inside TOUCH_MAX_CLICK_DIST (64) + for touch in [false, true] { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { time: Some(0.0), screen_rect: Some(area), ..Default::default() }, |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, a, touch), "first click only arms the pair"); + let second = pointer_click(&ctx, 0.25, mild, touch); + assert_eq!(second, touch, "touch={touch}: 20 px jitter is a double only for touch"); + } + // Touch-only: casual Framework-style drift beyond the previous 32 px budget. + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { time: Some(0.0), screen_rect: Some(area), ..Default::default() }, |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, a, true)); + assert!(pointer_click(&ctx, 0.25, wide, true), "48 px touch jitter is still a double"); + } + + /// A mouse double past the time window is not a double, even at the same pixel. + #[test] + fn second_click_respects_the_half_second_window() { + let area = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)); + let p = egui::pos2(40.0, 40.0); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { time: Some(0.0), screen_rect: Some(area), ..Default::default() }, |ui| { + ui.allocate_rect(area, egui::Sense::click()); + }); + out.textures_delta.clear(); + assert!(!pointer_click(&ctx, 0.1, p, false)); + assert!(!pointer_click(&ctx, 0.1 + super::DOUBLE_CLICK_S + 0.05, p, false), "past Windows' 0.5 s window"); + } + use egui_kittest::{Harness, kittest::Queryable}; + /// A panel footer's buttons are drawn whole. The bar runs on over the body's bottom margin, so a + /// body that clips where its content ends (as the panels do) cut the bottom off every button: + /// the hover highlight of a 26 px button was about 21 px tall in the Paths, Layers and Channels + /// panels (#6238). + #[test] + fn panel_footer_buttons_are_not_clipped_by_the_body() { + let mut h = Harness::new_ui_state( + |ui, s: &mut Option<(egui::Rect, egui::Rect, egui::Rect)>| { + // A body exactly as tall as the footer takes, clipped there. + let body = egui::Rect::from_min_size(ui.max_rect().min, egui::vec2(220.0, super::footer_height(ui))); + let mut body_ui = ui.new_child(egui::UiBuilder::new().max_rect(body)); + body_ui.set_clip_rect(body); + super::panel_footer(&mut body_ui, |bar| { + let b = crate::icons::button(bar, "square", 26.0, false, "Button"); + *s = Some((b.rect, bar.clip_rect(), body)); + }); + }, + None, + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + let (button, clip, body) = h.state().expect("the footer drew its button"); + assert!(button.bottom() > body.bottom(), "the bar really overruns the body, or this test proves nothing: {button:?} vs {body:?}"); + assert!(clip.contains_rect(button), "the whole button is inside the clip: {button:?} not in {clip:?}"); + } + /// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays /// local, so it isn't recoloured for the rest of the frame (selected rows, slider trails). #[test] @@ -2126,6 +2373,29 @@ mod tests { let _ = press(&mut h, Modifiers::NONE, Key::ArrowDown); } + /// #5914: Exposure's ±20 stops keep 0.01 arrow steps when the field shows two decimals. + #[test] + fn arrow_keys_step_fractional_stops_when_decimals_are_set() { + let mut h = Harness::new_ui_state( + |ui, v: &mut f32| { + super::slider_row_prec(ui, "Exposure", v, -20.0..=20.0, "", None, super::RowGestures::default(), Some(2)); + }, + 0.0, + ); + h.run(); + h.get_by_role(egui::accesskit::Role::SpinButton).click(); + h.run(); + h.key_press_modifiers(Modifiers::NONE, Key::ArrowUp); + h.run(); + assert!((*h.state() - 0.01).abs() < 1e-6); + h.key_press_modifiers(Modifiers::NONE, Key::ArrowUp); + h.run(); + assert!((*h.state() - 0.02).abs() < 1e-6); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowUp); + h.run(); + assert!((*h.state() - 0.12).abs() < 1e-6); + } + #[test] fn arrow_keys_round_decimals_step_fine_fields_and_clamp() { for (start, mods, key, want) in [ diff --git a/crates/ui-egui/src/workspace_ui.rs b/crates/ui-egui/src/workspace_ui.rs index 1c93272631..4fb9140021 100644 --- a/crates/ui-egui/src/workspace_ui.rs +++ b/crates/ui-egui/src/workspace_ui.rs @@ -45,13 +45,45 @@ const IDS: [&str; 7] = [ "view.show.showExtrasOptions", ]; +/// Window › Workspace items of saved workspaces: `window.workspace.saved.`, an index into +/// [`saved_names`]. +pub const SAVED: &str = "window.workspace.saved."; + /// Menu ids handled here (live menu items). pub fn handles(id: &str) -> bool { - IDS.contains(&id) + IDS.contains(&id) || id.starts_with(SAVED) +} + +/// Saved workspace names, in menu order. +pub fn saved_names(app: &PhotocraftApp) -> Vec { + app.session.prefs().workspaces.keys().cloned().collect() +} + +fn saved_name(app: &PhotocraftApp, id: &str) -> Option { + let i = id.strip_prefix(SAVED)?.parse::().ok()?; + app.session.prefs().workspaces.keys().nth(i).cloned() +} + +/// The top-bar workspace switcher's options as (key, label): the presets, then saved workspaces. +pub fn switcher_options(app: &PhotocraftApp) -> Vec<(String, String)> { + // Keys stay English (`apply_workspace` matches them); the dropdown shows the preset labels + // in the UI language. + let presets = [ + ("Essentials", tl!("Essentials")), + ("Photography", tl!("Photography")), + ("Painting", tl!("Painting")), + ("Graphic and Web", tl!("Graphic and Web")), + ("Pixel Art", tl!("Pixel Art")), + ("Motion", tl!("Motion")), + ]; + let mut opts: Vec<(String, String)> = presets.iter().map(|(k, l)| ((*k).to_string(), (*l).to_string())).collect(); + opts.extend(saved_names(app).into_iter().map(|n| (n.clone(), n))); + opts } pub fn checked(app: &PhotocraftApp, id: &str) -> Option { match id { + i if i.starts_with(SAVED) => Some(saved_name(app, i).is_some_and(|n| n == app.ui.workspace)), "window.panel.modifierKeys" => Some(app.ui.shell.modifier_keys), "window.workspace.lockWorkspace" => Some(app.session.prefs().workspace_locked), "view.pixelAspectRatio.custom" => Some(app.ui.view.pixel_aspect.starts_with("custom:")), @@ -140,6 +172,10 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option delete_workspace(app, params), "window.workspace.select" => select_workspace(app, params), + i if i.starts_with(SAVED) => match saved_name(app, i) { + Some(name) => select_workspace(app, &json!({"name": name})), + None => Err(format!("no saved workspace for `{i}`")), + }, "view.pixelAspectRatio.custom" if no_params(params) => open(app, "customPar", json!({"name": "", "ratio": 1.0})), "view.pixelAspectRatio.custom" => custom_par(app, params), "view.show.showExtrasOptions" if no_params(params) => { @@ -609,6 +645,40 @@ mod tests { assert!(s2.prefs().workspaces.contains_key("Mine")); } + #[test] + fn saved_workspaces_are_listed_checked_and_selectable() { + let (mut app, ctx) = app(); + let ws_items = + |app: &PhotocraftApp| crate::menus::menu_items(app).into_iter().filter(|i| i.path == ["Window", "Workspace"] && i.id != "---").collect::>(); + assert!(!ws_items(&app).iter().any(|i| i.id.starts_with(SAVED))); + app.ui.panels.history = true; + crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Retouch"})).unwrap(); + crate::menus::invoke(&mut app, &ctx, "window.workspace.newWorkspace", json!({"name": "Comp"})).unwrap(); + // Listed right after the presets, in name order; only the current one is checked. + let items = ws_items(&app); + let motion = items.iter().position(|i| i.id == "window.workspace.motion").unwrap(); + let saved: Vec<(&str, Option, bool)> = items[motion + 1..].iter().take(2).map(|i| (i.label.as_str(), i.checked, i.enabled)).collect(); + assert_eq!(saved, [("Comp", Some(true), true), ("Retouch", Some(false), true)]); + assert!(items.iter().filter(|i| !i.id.starts_with(SAVED)).all(|i| i.checked != Some(true) || i.id == "window.workspace.lockWorkspace")); + assert!(crate::menus::is_live(&format!("{SAVED}1"))); + // Switch to a preset, then back through the menu item. + crate::menus::invoke(&mut app, &ctx, "window.workspace.painting", json!({})).unwrap(); + assert!(!app.ui.panels.history); + let retouch = ws_items(&app).into_iter().find(|i| i.label == "Retouch").unwrap(); + crate::menus::invoke(&mut app, &ctx, &retouch.id, json!({})).unwrap(); + assert_eq!(app.ui.workspace, "Retouch"); + assert!(app.ui.panels.history); + assert_eq!(checked(&app, &retouch.id), Some(true)); + // Stale or malformed ids fail instead of panicking. + for bad in ["9", "-1", "x", ""] { + assert!(crate::menus::invoke(&mut app, &ctx, &format!("{SAVED}{bad}"), json!({})).is_err(), "{bad}"); + } + // The top-bar switcher lists them after the presets. + let keys: Vec = switcher_options(&app).into_iter().map(|(k, _)| k).collect(); + assert_eq!(keys[6..], ["Comp", "Retouch"]); + assert!(keys.contains(&app.ui.workspace)); + } + #[test] fn lock_workspace_toggles_and_persists() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/zoom_hold.rs b/crates/ui-egui/src/zoom_hold.rs new file mode 100644 index 0000000000..ec0d5f9376 --- /dev/null +++ b/crates/ui-egui/src/zoom_hold.rs @@ -0,0 +1,367 @@ +//! Animated Zoom: press and hold with the Zoom tool to zoom continuously (#6210). +//! +//! With Preferences › General › Animated Zoom on (the default, as in Photoshop) a press held +//! still past [`DELAY`] zooms in smoothly about the press point until the button comes up, or out +//! with ⌥. The release is then no click step. A quick click still steps the zoom (the canvas), a +//! drag still scrubs or draws a zoom rectangle (`zoom_tool.rs`), and with the preference off a +//! hold does nothing more than a click. +//! +//! The rate is per second of wall time, so the zoom grows the same at any frame rate. + +use egui::Pos2; + +use crate::canvas::ViewXform; +use crate::state::View; + +/// Seconds a press must be held still before it zooms continuously (a click is shorter). +pub const DELAY: f64 = 0.3; +/// Doublings (or halvings, zooming out) per second while held. +pub const RATE: f32 = 2.0; +/// Screen points the pointer may wander before the press counts as a drag instead. +const SLOP: f32 = 6.0; +/// The longest frame gap a hold zooms over: a stalled frame never jumps the zoom far. +const MAX_DT: f64 = 0.25; + +/// A Zoom-tool press being held. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Hold { + /// Screen point where the button went down. + anchor: Pos2, + /// The document point under it (it stays under the anchor). + doc: [f64; 2], + /// When the button went down (egui seconds). + t0: f64, + /// The time the zoom last advanced to, once zooming. + last: Option, + /// The pointer moved before the hold began: this press is a drag, never a hold. + dragged: bool, +} + +/// Where a canvas keeps its hold: each canvas (document `idx` of the tiled windows, in the main +/// or a secondary window) has its own, so a hold on one never zooms another. +pub fn hold_id(idx: usize, primary: bool, viewport: egui::ViewportId) -> egui::Id { + egui::Id::new(("pc-zoom-tool-hold", idx, primary, viewport)) +} + +/// The zoom after holding for `dt` seconds at `zoom` (out: shrinking), within the range for a +/// document of `size` px. A non-finite or negative `dt` changes nothing. +pub fn hold_zoom(zoom: f32, dt: f64, out: bool, size: [u32; 2]) -> f32 { + let dt = if dt.is_finite() { dt.clamp(0.0, MAX_DT) as f32 } else { 0.0 }; + let e = if out { -RATE * dt } else { RATE * dt }; + crate::zoom_levels::clamp(zoom * e.exp2(), size) +} + +/// Is a held press zooming right now? (It owns the gesture: no scrub, no click step.) +pub fn active(ctx: &egui::Context, id: egui::Id) -> bool { + ctx.data(|d| d.get_temp::(id)).is_some_and(|h| h.last.is_some()) +} + +/// Advance the Zoom-tool hold of the canvas whose [`hold_id`] is `id` by one frame. `enabled` is +/// the Animated Zoom preference, `out` the zoom direction (⌥ or a temporary zoom-out key, read +/// live), `on_canvas` whether the canvas owns the press (not a dialog, a scrollbar or the compass +/// over it); only such a press can become a hold. Returns true when the press is a hold zoom (on +/// its release frame too), so the canvas skips the scrub and the click step. +#[allow(clippy::too_many_arguments)] +pub fn update(ctx: &egui::Context, view: &mut View, xf: &ViewXform, enabled: bool, out: bool, ppp: f32, on_canvas: bool, id: egui::Id) -> bool { + let (down, origin, start, pos, now) = + ctx.input(|i| (i.pointer.primary_down(), i.pointer.press_origin(), i.pointer.press_start_time(), i.pointer.latest_pos(), i.time)); + let held = ctx.data(|d| d.get_temp::(id)); + if !enabled || !down { + if held.is_some() { + ctx.data_mut(|d| d.remove::(id)); + } + return enabled && held.is_some_and(|h| h.last.is_some()); + } + let start = start.filter(|t| t.is_finite()); + // A hold left from an earlier press (the tool changed before its release, so no frame here + // saw the button come up) is not this press's. + let mine = |h: &Hold| origin == Some(h.anchor) && start.is_none_or(|t| t == h.t0); + let mut h = match held.filter(mine) { + Some(h) => h, + None => { + // A press that began on the canvas (not one dragged in from a panel) and that the + // canvas owns. + let Some(anchor) = origin.filter(|p| on_canvas && xf.rect.contains(*p)) else { + ctx.data_mut(|d| d.remove::(id)); + return false; + }; + let t0 = start.unwrap_or(now); + let h = Hold { anchor, doc: xf.to_doc(anchor), t0, last: None, dragged: false }; + ctx.data_mut(|d| d.insert_temp(id, h)); + h + } + }; + if h.dragged { + return false; + } + if h.last.is_none() { + // Moved before the hold began: a drag (scrubby zoom or rectangle), not a hold. + if pos.is_some_and(|p| p.distance(h.anchor) > SLOP) { + h.dragged = true; + ctx.data_mut(|d| d.insert_temp(id, h)); + return false; + } + let begin = h.t0 + DELAY; + if now < begin { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((begin - now).clamp(0.0, DELAY))); + return false; + } + h.last = Some(begin); + } + let dt = now - h.last.unwrap_or(now); + let zoom = hold_zoom(view.zoom, dt, out, view.doc_size); + // At the zoom limit the view stays as it is (a window wrapped around the image centred it). + if zoom.to_bits() != view.zoom.to_bits() { + crate::zoom_tool::anchor_view(view, xf, h.anchor, h.doc, zoom, ppp); + } + h.last = Some(now); + ctx.data_mut(|d| d.insert_temp(id, h)); + ctx.request_repaint(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PhotocraftApp; + use egui::{Event, PointerButton, vec2}; + use egui_kittest::Harness; + use serde_json::json; + + const DOC: [u32; 2] = [400, 300]; + + /// The hold of the harness's one canvas. + fn hid() -> egui::Id { + hold_id(0, true, egui::ViewportId::ROOT) + } + + #[test] + fn hold_zoom_is_frame_rate_independent_and_safe() { + // One second in one step or in 60 steps: two doublings either way. + let mut z = 1.0; + for _ in 0..60 { + z = hold_zoom(z, 1.0 / 60.0, false, DOC); + } + let big = hold_zoom(hold_zoom(hold_zoom(hold_zoom(1.0, 0.25, false, DOC), 0.25, false, DOC), 0.25, false, DOC), 0.25, false, DOC); + assert!((z - 4.0).abs() < 1e-3 && (big - 4.0).abs() < 1e-3, "{z} {big}"); + assert!((hold_zoom(1.0, 0.25, true, DOC) - 2f32.powf(-0.5)).abs() < 1e-5); + // Nothing for no time, a negative or broken dt, or a non-finite zoom (100 %). + assert_eq!(hold_zoom(1.5, 0.0, false, DOC), 1.5); + assert_eq!(hold_zoom(1.5, -3.0, false, DOC), 1.5); + assert_eq!(hold_zoom(1.5, f64::NAN, true, DOC), 1.5); + assert_eq!(hold_zoom(1.5, f64::INFINITY, true, DOC), 1.5); + assert_eq!(hold_zoom(f32::NAN, 0.1, false, DOC), 1.0); + // A stall is capped; the range is kept. + assert!((hold_zoom(1.0, 10.0, false, DOC) - 2f32.powf(0.5)).abs() < 1e-5); + assert_eq!(hold_zoom(crate::zoom_levels::MAX, 0.2, false, DOC), crate::zoom_levels::MAX); + let min = crate::zoom_levels::min(DOC); + assert_eq!(hold_zoom(min, 0.2, true, DOC), min); + assert_eq!(hold_zoom(1.0, 0.1, false, [0, 0]), hold_zoom(1.0, 0.1, false, [0, 0])); + } + + fn harness(animated: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"values": {"general.animatedZoom": animated, "tools.zoomClickedPointToCenter": false}})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = crate::state::Tool::Zoom; + app.ui.tool_options.zoom_scrubby = true; + // Each step is 0.1 s of egui time. + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.1).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let view = &mut h.state_mut().ui.views[0]; + view.zoom = 1.0; + view.center = [200.0, 150.0]; + view.fit_pending = false; + view.fill_pending = false; + h.run_steps(2); + h + } + + fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + ViewXform::active(h.state()).unwrap() + } + + fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, alt: bool) { + let modifiers = egui::Modifiers { alt, ..Default::default() }; + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers }); + h.step(); + } + + /// Press at `p`, hold still for `frames` steps, release; returns the zoom after every step, + /// then after the release step and two more frames. + fn hold(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, frames: usize, alt: bool) -> Vec { + h.event(Event::PointerMoved(p)); + h.step(); + button(h, p, true, alt); + let mut zs = vec![h.state().current_zoom()]; + for _ in 0..frames { + h.step(); + zs.push(h.state().current_zoom()); + } + button(h, p, false, alt); + zs.push(h.state().current_zoom()); + h.run_steps(2); + zs.push(h.state().current_zoom()); + zs + } + + #[test] + fn holding_zooms_in_continuously_about_the_press_point() { + let mut h = harness(true); + let p = h.state().last_canvas_rect.center() + vec2(-120.0, 70.0); + let doc = xf(&h).to_doc(p); + let zs = hold(&mut h, p, 8, false); + // Nothing during the delay, then more every frame; the release adds no click step. + assert_eq!(zs[0], 1.0, "{zs:?}"); + let rising = &zs[4..=8]; + assert!(rising.windows(2).all(|w| w[1] > w[0]), "{zs:?}"); + // The release frame ends it: no click step, nothing more afterwards. + assert_eq!(zs[10], zs[9], "{zs:?}"); + assert!(zs[9] < zs[8] * 1.2, "{zs:?}"); + // About 0.5 s held past the delay at RATE doublings per second. + let expected = (RATE * 0.5).exp2(); + assert!((zs[8] - expected).abs() < 0.2, "{zs:?} vs {expected}"); + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5); + assert_eq!(h.state().session.documents()[0].history.past_len(), 0); + } + + #[test] + fn alt_hold_zooms_out() { + let mut h = harness(true); + let p = h.state().last_canvas_rect.center() + vec2(50.0, -30.0); + let doc = xf(&h).to_doc(p); + let zs = hold(&mut h, p, 8, true); + assert!(zs[4..=8].windows(2).all(|w| w[1] < w[0]), "{zs:?}"); + assert_eq!(zs[10], zs[9], "{zs:?}"); + assert!(zs[9] > zs[8] / 1.2, "{zs:?}"); + assert!(xf(&h).to_screen(doc[0] as f32, doc[1] as f32).distance(p) < 0.5); + } + + #[test] + fn a_hold_at_the_zoom_limit_leaves_the_view_alone() { + // Zoom Resizes Windows centres the image in a window wrapped around it + // (zoom_window_fit.rs); a hold that can't zoom further must not move it back. + let mut h = harness(true); + let p = h.state().last_canvas_rect.center() + vec2(50.0, -30.0); + h.event(Event::PointerMoved(p)); + h.step(); + button(&mut h, p, true, true); + let min = crate::zoom_levels::min(DOC); + for _ in 0..200 { + if h.state().current_zoom() == min { + break; + } + h.step(); + } + assert_eq!(h.state().current_zoom(), min); + h.state_mut().ui.views[0].center = [200.0, 150.0]; + h.run_steps(3); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0]); + button(&mut h, p, false, true); + } + + #[test] + fn with_animated_zoom_off_a_hold_is_one_click_step() { + let mut h = harness(false); + let p = h.state().last_canvas_rect.center(); + // Shorter than egui's longest click, so the release is a click. + let zs = hold(&mut h, p, 5, false); + assert!(zs[..=5].iter().all(|&z| z == 1.0), "{zs:?}"); + assert_eq!(*zs.last().unwrap(), crate::zoom_levels::step(1.0, 1, DOC)); + } + + #[test] + fn a_quick_click_steps_and_a_drag_still_scrubs() { + let mut h = harness(true); + let p = h.state().last_canvas_rect.center(); + let zs = hold(&mut h, p, 0, false); + assert_eq!(*zs.last().unwrap(), crate::zoom_levels::step(1.0, 1, DOC), "{zs:?}"); + // Scrubby Zoom: a drag zooms by its distance however long it is held, never by time. + let z0 = h.state().current_zoom(); + h.event(Event::PointerMoved(p)); + h.step(); + button(&mut h, p, true, false); + h.event(Event::PointerMoved(p + vec2(50.0, 0.0))); + h.step(); + for _ in 0..6 { + h.step(); + } + let expected = crate::zoom_tool::scrub_zoom(z0, 50.0, DOC); + assert!((h.state().current_zoom() - expected).abs() < 1e-4, "{} vs {expected}", h.state().current_zoom()); + button(&mut h, p + vec2(50.0, 0.0), false, false); + h.run_steps(2); + assert!((h.state().current_zoom() - expected).abs() < 1e-4); + assert!(!active(&h.ctx, hid())); + } + + #[test] + fn a_hold_is_only_reused_by_its_own_press_on_its_own_canvas() { + // The Hold only goes away on a Zoom-tool frame with the button up. When the tool changed + // before the release, the next Zoom press can be the first such frame: it is a new press. + let h = harness(true); + let (xf, view) = (xf(&h), h.state().ui.views[0].clone()); + let p = xf.rect.center(); + let press = |t: f64| egui::RawInput { + time: Some(t), + events: vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }], + ..Default::default() + }; + let elsewhere = p + vec2(-100.0, 30.0); + let outside = xf.rect.right_bottom() + vec2(50.0, 50.0); + for stale in [ + Hold { anchor: elsewhere, doc: xf.to_doc(elsewhere), t0: 1.0, last: Some(1.6), dragged: false }, + Hold { anchor: elsewhere, doc: xf.to_doc(elsewhere), t0: 1.0, last: None, dragged: true }, + // Same point, earlier press. + Hold { anchor: p, doc: xf.to_doc(p), t0: 1.0, last: Some(1.6), dragged: false }, + // A hold on another (tiled) canvas. + Hold { anchor: outside, doc: [0.0, 0.0], t0: 10.0, last: Some(10.0), dragged: false }, + ] { + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_temp(hid(), stale)); + let mut v = view.clone(); + ctx.begin_pass(press(10.0)); + let zooming = update(&ctx, &mut v, &xf, true, false, 1.0, true, hid()); + ctx.end_pass().textures_delta.clear(); + assert!(!zooming, "{stale:?}"); + assert_eq!(v.zoom, view.zoom, "{stale:?}"); + let fresh = ctx.data(|d| d.get_temp::(hid())); + assert_eq!(fresh.map(|h| (h.anchor, h.last, h.dragged)), Some((p, None, false)), "{stale:?}"); + } + // Tiled windows: canvas 0's hold zooms canvas 0 only. Canvas 1 (which doesn't own the + // press) neither picks it up nor ends it. + let ctx = egui::Context::default(); + let ours = Hold { anchor: p, doc: xf.to_doc(p), t0: 10.0, last: Some(10.0), dragged: false }; + ctx.data_mut(|d| d.insert_temp(hid(), ours)); + let mut v = view.clone(); + ctx.begin_pass(press(10.0)); + let other = hold_id(1, true, egui::ViewportId::ROOT); + assert!(!update(&ctx, &mut v, &xf, true, false, 1.0, false, other)); + ctx.end_pass().textures_delta.clear(); + assert_eq!(v.zoom, view.zoom); + assert_eq!(ctx.data(|d| d.get_temp::(hid())), Some(ours)); + } + + #[test] + fn a_press_the_canvas_does_not_own_never_becomes_a_hold() { + let mut h = harness(true); + crate::dialogs::open_command_dialog(h.state_mut(), "image.adjustments.brightnessContrast", "Brightness/Contrast…"); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1); + let p = h.state().last_canvas_rect.center() + vec2(-120.0, 70.0); + let zs = hold(&mut h, p, 8, false); + assert!(zs.iter().all(|&z| z == 1.0), "{zs:?}"); + assert!(!active(&h.ctx, hid())); + } +} diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs index e28e7e9c0a..1a789c7e28 100644 --- a/crates/ui-egui/src/zoom_tool.rs +++ b/crates/ui-egui/src/zoom_tool.rs @@ -42,6 +42,8 @@ pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 { /// Set `view` to `zoom` (device pixels per document pixel) with document point `doc` shown at /// screen point `screen` (egui points) on a display with `ppp` physical pixels per point. pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32, ppp: f32) { + view.zoom_animating = false; + view.zoom_anchor = None; view.zoom = zoom; let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; let d = xf.unmap_vec((screen - xf.rect.center()) / (zoom / ppp)); @@ -63,7 +65,14 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { match z.rect_to.as_mut() { Some(to) => *to = p, - None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size), app.ppp), + None => { + // Only a new zoom re-anchors: a window that Zoom Resizes Windows grew around the + // image has centred it since (zoom_window_fit.rs), and the release keeps that. + let zoom = scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size); + if zoom.to_bits() != view.zoom.to_bits() { + anchor_view(view, xf, z.anchor, z.doc, zoom, app.ppp); + } + } } ctx.data_mut(|d| d.insert_temp(id(), z)); } @@ -194,6 +203,253 @@ mod tests { assert_eq!(h.state().current_zoom(), crate::zoom_levels::step(zc, 1, h.state().ui.views[0].doc_size)); } + #[test] + fn animated_zoom_off_click_jumps_to_the_anchored_target() { + let mut h = harness(true, 1.0); + h.state_mut() + .run("prefs.set", json!({"values": {"general.animatedZoom": false, "tools.zoomClickedPointToCenter": false, "tools.overscroll": true}})) + .unwrap(); + let pointer = h.state().last_canvas_rect.center() + vec2(90.0, -45.0); + let anchor = view_xf(&h).to_doc(pointer); + let start = h.state().current_zoom(); + h.event(Event::PointerMoved(pointer)); + h.step(); + press(&mut h, pointer, true); + press(&mut h, pointer, false); + let target = crate::zoom_levels::step(start, 1, [400, 300]); + let v = &h.state().ui.views[0]; + assert_eq!(v.zoom, target); + assert_eq!(view_xf(&h).zoom, target); + assert!(!v.zoom_animating && v.zoom_anchor.is_none() && v.zoom_transition.is_none()); + assert!(view_xf(&h).to_screen(anchor[0] as f32, anchor[1] as f32).distance(pointer) < 0.5); + } + + #[test] + fn animated_zoom_off_view_commands_show_the_target_on_the_first_frame() { + for command in + ["view.zoomIn", "view.zoomOut", "view.actualPixels", "view.fitOnScreen", "view.twoHundredPercent", "view.printSize", "view.fitLayersOnScreen"] + { + let mut h = harness(true, 1.0); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + if crate::view_cmds::handles(command) { + crate::view_cmds::invoke(h.state_mut(), &egui::Context::default(), command, &json!({})).unwrap().unwrap(); + } else { + h.state_mut().run(command, json!({})).unwrap(); + } + assert!(!h.state().ui.views[0].zoom_animating, "{command}"); + h.step(); + let v = &h.state().ui.views[0]; + let xf = view_xf(&h); + assert_eq!((xf.zoom, xf.center), (v.zoom, v.center), "{command}"); + assert!(v.zoom_anchor.is_none() && v.zoom_transition.is_none(), "{command}"); + } + } + + #[test] + fn disabling_animated_zoom_during_a_transition_snaps_and_does_not_resume() { + let mut h = harness(true, 1.0); + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + h.step(); + let time = h.ctx.input(|i| i.time) + 0.11; + h.input_mut().time = Some(time); + h.step(); + assert!(h.state().ui.views[0].zoom_animating); + let target = (h.state().ui.views[0].zoom, h.state().ui.views[0].center); + assert_ne!(view_xf(&h).zoom, target.0); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + assert_eq!((view_xf(&h).zoom, view_xf(&h).center), target, "pointer mapping switches immediately"); + h.step(); + let v = &h.state().ui.views[0]; + assert!(!v.zoom_animating && v.zoom_anchor.is_none() && v.zoom_transition.is_none()); + assert_eq!((view_xf(&h).zoom, view_xf(&h).center), target); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": true}})).unwrap(); + h.step(); + assert!(!h.state().ui.views[0].zoom_animating); + assert_eq!((view_xf(&h).zoom, view_xf(&h).center), target); + } + + #[test] + fn discrete_zoom_animates_and_keeps_its_document_anchor_fixed() { + let mut h = harness(true, 1.0); + let r = h.state().last_canvas_rect; + let pointer = r.center() + vec2(90.0, -45.0); + let anchor_doc = view_xf(&h).to_doc(pointer); + let anchor_screen = if h.state().session.prefs().tools.zoom_clicked_point_to_center { r.center() } else { pointer }; + let start = h.state().current_zoom(); + + h.event(Event::PointerMoved(pointer)); + h.step(); + press(&mut h, pointer, true); + press(&mut h, pointer, false); + + let target = crate::zoom_levels::step(start, 1, h.state().ui.views[0].doc_size); + assert_eq!(h.state().current_zoom(), target, "the model keeps the requested zoom as its target"); + assert!(h.state().ui.views[0].zoom_animating); + let mut time = h.ctx.input(|i| i.time) + 0.05; + for _ in 0..5 { + time += 0.01; + h.input_mut().time = Some(time); + h.step(); + } + let displayed_xf = view_xf(&h); + let displayed = displayed_xf.to_screen(anchor_doc[0] as f32, anchor_doc[1] as f32); + assert!(displayed.distance(anchor_screen) < 0.5, "anchor moved to {displayed:?}"); + let intermediate = displayed_xf.zoom; + assert!(intermediate > start && intermediate < target, "expected an intermediate zoom, got {intermediate}"); + + for _ in 0..30 { + time += 0.01; + h.input_mut().time = Some(time); + h.step(); + } + assert!(!h.state().ui.views[0].zoom_animating); + assert_eq!(view_xf(&h).zoom, target); + assert_eq!(h.state().current_zoom(), target); + } + + #[test] + fn gpu_zoom_animation_reuses_the_document_composite() { + let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let renderer = egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs); + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_pixels_per_point(1.0).renderer(renderer).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.set_wgpu(cc.wgpu_render_state.clone().unwrap()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("edit.fill", json!({"color": "#2060c0"})).unwrap(); + app + }); + h.run_steps(4); + h.state_mut().run("view.actualPixels", json!({})).unwrap(); + h.step(); + let time = h.ctx.input(|i| i.time) + 0.3; + h.input_mut().time = Some(time); + h.step(); + let id = h.state().session.documents()[0].doc.id; + assert!(h.state().canvases[&(id, crate::canvas::GPU_OUTPUT)].on_gpu); + let directory = std::env::var_os("CRAFT_UI_ZOOM_FIXTURES").map(std::path::PathBuf::from); + if let Some(directory) = &directory { + std::fs::create_dir_all(directory).unwrap(); + } + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + let target = h.state().current_zoom(); + for (name, elapsed) in [("start", 0.0), ("midpoint", 0.11), ("finished", 0.23)] { + h.state_mut().perf.last_refresh = ""; + h.input_mut().time = Some(time + elapsed); + h.step(); + assert!(h.state().perf.gpu, "{name}: keep the GPU canvas"); + assert_eq!(h.state().perf.last_refresh, "", "{name}: reuse the composite"); + assert!(h.state().canvases.values().all(|cache| cache.texture.is_none()), "{name}: no CPU texture fallback"); + let xf = view_xf(&h); + if name == "midpoint" { + assert!(xf.zoom > 1.0 && xf.zoom < target); + } + let image = h.render().unwrap(); + let center = xf.to_screen(200.0, 150.0); + let pixel = image.get_pixel(center.x as u32, center.y as u32); + assert!(pixel[2] > pixel[0] * 2, "{name}: document remains visible: {pixel:?}"); + if let Some(directory) = &directory { + image.save(directory.join(format!("photocraft-zoom-{name}.png"))).unwrap(); + } + } + assert!(!h.state().ui.views[0].zoom_animating); + assert_eq!(view_xf(&h).zoom, target); + h.state_mut().run("prefs.set", json!({"values": {"general.animatedZoom": false}})).unwrap(); + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + h.state_mut().perf.last_refresh = ""; + h.step(); + assert_eq!(view_xf(&h).zoom, h.state().current_zoom()); + assert!(!h.state().ui.views[0].zoom_animating); + assert!(h.state().perf.gpu); + assert_eq!(h.state().perf.last_refresh, "", "immediate zoom reuses the GPU composite"); + let image = h.render().unwrap(); + if let Some(directory) = &directory { + image.save(directory.join("photocraft-zoom-disabled.png")).unwrap(); + } + } + + #[test] + fn view_commands_retarget_a_zoom_click_without_retaining_its_anchor() { + for command in ["view.zoomIn", "view.actualPixels", "view.fitOnScreen"] { + let mut h = harness(true, 1.0); + let pointer = h.state().last_canvas_rect.center() + vec2(90.0, -45.0); + h.event(Event::PointerMoved(pointer)); + h.step(); + press(&mut h, pointer, true); + press(&mut h, pointer, false); + assert!(h.state().ui.views[0].zoom_anchor.is_some()); + h.step(); + h.state_mut().run(command, json!({})).unwrap(); + assert!(h.state().ui.views[0].zoom_anchor.is_none(), "{command}"); + h.step(); + let time = h.ctx.input(|i| i.time) + 0.3; + h.input_mut().time = Some(time); + h.step(); + let target = &h.state().ui.views[0]; + assert!(!target.zoom_animating, "{command}"); + assert_eq!(view_xf(&h).zoom, target.zoom); + assert_eq!(view_xf(&h).center, target.center); + } + } + + #[test] + fn wheel_pinch_pan_and_scrubby_zoom_interrupt_a_transition() { + for gesture in 0..5 { + let mut h = harness(true, 1.0); + let pointer = h.state().last_canvas_rect.center(); + h.event(Event::PointerMoved(pointer)); + h.step(); + h.state_mut().run("view.zoomIn", json!({})).unwrap(); + h.step(); + assert!(h.state().ui.views[0].zoom_animating); + match gesture { + 0 => h.event(Event::Zoom(1.25)), + 1 | 2 => h.event(Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers { alt: gesture == 1, ..Default::default() }, + }), + _ => { + h.state_mut().ui.tool = if gesture == 3 { crate::state::Tool::Hand } else { crate::state::Tool::Zoom }; + press(&mut h, pointer, true); + h.event(Event::PointerMoved(pointer + vec2(120.0, 0.0))); + } + } + h.step(); + assert!(!h.state().ui.views[0].zoom_animating, "gesture {gesture}"); + h.step(); + let target = &h.state().ui.views[0]; + assert_eq!(view_xf(&h).zoom, target.zoom, "gesture {gesture}"); + assert_eq!(view_xf(&h).center, target.center, "gesture {gesture}"); + } + } + + #[test] + fn a_scrub_release_keeps_a_view_centred_since_its_last_zoom() { + // Zoom Resizes Windows centres the image once its window wraps it (zoom_window_fit.rs): + // the release frame, at the same zoom, must not pull it back to the press point. + let mut h = harness(true, 1.0); + let p0 = h.state().last_canvas_rect.center() + vec2(-150.0, 60.0); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true); + let end = p0 + vec2(90.0, 0.0); + h.event(Event::PointerMoved(end)); + h.step(); + let z = h.state().current_zoom(); + h.state_mut().ui.views[0].center = [200.0, 150.0]; + h.step(); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0], "a held, unmoved scrub keeps the view"); + press(&mut h, end, false); + h.run_steps(2); + assert_eq!(h.state().current_zoom(), z); + assert_eq!(h.state().ui.views[0].center, [200.0, 150.0], "the release keeps the view"); + } + #[test] fn zoom_drags_keep_the_press_point_in_rotated_and_flipped_views() { for (scrubby, out, rotation, flip, ppp) in [ diff --git a/crates/ui-egui/src/zoom_window_fit.rs b/crates/ui-egui/src/zoom_window_fit.rs new file mode 100644 index 0000000000..a44692c14f --- /dev/null +++ b/crates/ui-egui/src/zoom_window_fit.rs @@ -0,0 +1,434 @@ +//! Preferences › General › Zoom Resizes Windows, which is also the Zoom tool's Resize Windows to +//! Fit (#204). +//! +//! As in Photoshop the two are one setting, so turning it on in either place shows it on in the +//! other. With it on, zooming a document that floats in its own window resizes that window to wrap +//! the image at the new zoom, no bigger than the screen it is on; with it off (the default) the +//! image zooms inside a window that keeps its size. Tabbed documents never resize the main window. +//! +//! Only a change of zoom resizes. Fit on Screen derives the zoom from the window, so a fit leaves +//! the window alone and the two can never chase each other. +//! +//! A zoom about the pointer leaves the image off centre, so a window grown to wrap it also +//! re-centres the image in what the scrollbars leave free; a window the screen caps keeps the +//! zoomed spot where it is. + +use egui::{Context, Id, Pos2, Rect, Vec2, ViewportClass, ViewportCommand, ViewportId, vec2}; + +use crate::PhotocraftApp; +use crate::state::{DocWindow, View}; + +/// The smallest window a zoom asks for (points), so zooming far out keeps the window usable. +pub const MIN_SIZE: Vec2 = vec2(240.0, 160.0); + +/// Room left on the screen for the window's title bar and the taskbar or Dock (points). +pub const SCREEN_MARGIN: f32 = 48.0; + +/// What wrapping a document window needs besides the view itself. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Layout { + /// The window's device pixels per egui point (`View::zoom` is in device pixels). + pub ppp: f32, + /// View › Pixel Aspect Ratio Correction: document pixels are shown this much wider. + pub aspect: f32, + /// The window's own chrome around the image, the rulers, in points. + pub chrome: Vec2, + /// The scrollbars along the right and bottom (points; 0 when hidden). They are drawn over the + /// canvas, so the window makes room for them too. + pub bars: f32, + /// View › Flip Horizontal. + pub flip: bool, + /// The size of the screen the window is on, in points, when the platform reports it. + pub monitor: Option, +} + +/// The inner size (points) of a window that wraps `view`'s document at its zoom and rotation, +/// at least [`MIN_SIZE`] but never bigger than the screen, even a screen smaller than that +/// minimum. `None` until the view has a document size. +pub fn wrapped_size(view: &View, layout: &Layout) -> Option { + wrap(view, layout).map(|(_, size)| size) +} + +/// The window size the image and chrome need, and the size [`wrapped_size`] asks for. +fn wrap(view: &View, layout: &Layout) -> Option<(Vec2, Vec2)> { + let usable = |x: f32| x.is_finite() && x > 0.0; + if !usable(view.zoom) || !usable(layout.ppp) { + return None; + } + let aspect = if usable(layout.aspect) { layout.aspect } else { 1.0 }; + let w = view.doc_size[0] as f32 * aspect * view.zoom / layout.ppp; + let h = view.doc_size[1] as f32 * view.zoom / layout.ppp; + if !usable(w) || !usable(h) { + return None; + } + // A turned view (View › Rotate View) shows the image's bounding box. + let (sin, cos) = view.rotation.to_radians().sin_cos(); + let (sin, cos) = (sin.abs(), cos.abs()); + let image = vec2(w * cos + h * sin, w * sin + h * cos); + let bars = if usable(layout.bars) { layout.bars } else { 0.0 }; + let chrome = if layout.chrome.is_finite() { layout.chrome.max(Vec2::ZERO) } else { Vec2::ZERO } + Vec2::splat(bars); + // The screen is the hard cap: applied last, it wins over the minimum on a tiny screen. + let screen = + layout.monitor.filter(|m| usable(m.x) && usable(m.y)).map_or(Vec2::splat(f32::INFINITY), |m| (m - Vec2::splat(SCREEN_MARGIN)).max(Vec2::splat(1.0))); + Some((image + chrome, (image + chrome).max(MIN_SIZE).min(screen))) +} + +/// The view centre that shows the whole image in the middle of a window of [`wrapped_size`]: +/// the image centred in the canvas less its scrollbars. `None` when the screen capped the window +/// and the image doesn't fit, so the zoomed spot stays put. +pub fn wrapped_center(view: &View, layout: &Layout) -> Option<[f32; 2]> { + let (needed, size) = wrap(view, layout)?; + if needed.x > size.x + 0.5 || needed.y > size.y + 0.5 { + return None; + } + let bars = if layout.bars.is_finite() && layout.bars > 0.0 { layout.bars } else { 0.0 }; + let middle = [view.doc_size[0] as f32 / 2.0, view.doc_size[1] as f32 / 2.0]; + // The canvas centre sits half a bar right of and below the middle of the free area. + let xf = crate::canvas::ViewXform { + rect: Rect::from_center_size(Pos2::ZERO, Vec2::ZERO), + zoom: view.zoom / layout.ppp, + center: middle, + flip: layout.flip, + rotation: view.rotation, + aspect: layout.aspect, + }; + let [x, y] = xf.to_doc(Pos2::new(bars / 2.0, bars / 2.0)); + let center = [x as f32, y as f32]; + center.iter().all(|c| c.is_finite()).then_some(center) +} + +/// Where the zoom a window was last wrapped for (or first seen at) is kept between frames. +fn last_zoom_id(window: u64) -> Id { + Id::new(("pc-zoom-resizes-windows", window)) +} + +/// The inner size floating window `window` should take this frame, if any: when `on` (the +/// preference) and its zoom moved from `before` (its view as the frame began) to `after` (the view +/// its canvas left), other than by a fit. Every call records the zoom, so the first frame of a new +/// window and the frames with the preference off never resize later. +pub fn resize_for(ctx: &Context, window: u64, on: bool, before: &View, after: &View, layout: &Layout) -> Option { + let id = last_zoom_id(window); + let last = ctx.data_mut(|d| { + let last = d.get_temp::(id); + d.insert_temp(id, after.zoom); + last + }); + let fitting = before.fit_pending || before.fill_pending || after.fit_pending || after.fill_pending; + // Compare the bits: any change of zoom counts, and NaN never matches itself into a resize loop. + if !on || fitting || last?.to_bits() == after.zoom.to_bits() { + return None; + } + wrapped_size(after, layout) +} + +/// Called by a floating document window (`canvas::extra_windows`) after its canvas drew in `rect`: +/// with Zoom Resizes Windows on, a zoom resizes the window around the image. A window drawn +/// embedded in the main one (a backend without multiple windows, the web) is never resized, as the +/// command would resize the main window instead, and a maximized or full-screen one keeps the +/// size the user gave it. A window that grows around the whole image also centres it in `after`. +/// Returns the size asked for. +pub fn follow( + app: &PhotocraftApp, + ctx: &Context, + class: ViewportClass, + viewport: ViewportId, + window: &DocWindow, + after: &mut View, + rect: Rect, +) -> Option { + if class != ViewportClass::Immediate { + return None; + } + // Still recorded while maximized, so leaving it doesn't resize for a zoom made meanwhile. + let pinned = ctx.input(|i| i.viewport().maximized == Some(true) || i.viewport().fullscreen == Some(true)); + let layout = Layout { + ppp: ctx.pixels_per_point(), + aspect: app.ui.view.display_aspect(), + chrome: rect.size() - crate::rulers::content_rect(app, rect).size(), + bars: if app.ui.view.shows_scrollbars() { crate::scrollbars::THICKNESS } else { 0.0 }, + flip: app.ui.view.flip_horizontal, + monitor: ctx.input(|i| i.viewport().monitor_size), + }; + let on = app.session.prefs().general.zoom_resizes_windows && !pinned; + let size = resize_for(ctx, window.id, on, &window.view, after, &layout)?; + ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(size)); + if let Some(center) = wrapped_center(after, &layout) { + after.center = center; + // The canvas drew before this: show the centred view on the next frame. + ctx.request_repaint_of(viewport); + } + Some(size) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn view(doc_size: [u32; 2], zoom: f32) -> View { + View { zoom, doc_size, fit_pending: false, ..View::default() } + } + + fn layout() -> Layout { + Layout { ppp: 1.0, aspect: 1.0, chrome: Vec2::ZERO, bars: 0.0, flip: false, monitor: Some(vec2(1920.0, 1080.0)) } + } + + #[test] + fn the_window_wraps_the_image_at_its_zoom() { + assert_eq!(wrapped_size(&view([400, 300], 1.0), &layout()), Some(vec2(400.0, 300.0))); + assert_eq!(wrapped_size(&view([400, 300], 2.0), &layout()), Some(vec2(800.0, 600.0))); + // `zoom` is device pixels: on a 2× display 100% is half as many points. + assert_eq!(wrapped_size(&view([800, 600], 1.0), &Layout { ppp: 2.0, ..layout() }), Some(vec2(400.0, 300.0))); + // The rulers sit inside the window, around the image. + assert_eq!(wrapped_size(&view([400, 300], 1.0), &Layout { chrome: vec2(16.0, 16.0), ..layout() }), Some(vec2(416.0, 316.0))); + // Pixel Aspect Ratio Correction shows the pixels wider. + assert_eq!(wrapped_size(&view([400, 300], 1.0), &Layout { aspect: 2.0, ..layout() }), Some(vec2(800.0, 300.0))); + } + + #[test] + fn the_window_stays_between_the_minimum_and_the_screen() { + assert_eq!(wrapped_size(&view([400, 300], 0.05), &layout()), Some(MIN_SIZE)); + let screen = vec2(1920.0, 1080.0) - Vec2::splat(SCREEN_MARGIN); + assert_eq!(wrapped_size(&view([400, 300], 16.0), &layout()), Some(screen)); + // One axis can be clamped while the other still wraps. + assert_eq!(wrapped_size(&view([4000, 300], 1.0), &layout()), Some(vec2(screen.x, 300.0))); + // Without a reported screen only the minimum applies. + assert_eq!(wrapped_size(&view([400, 300], 16.0), &Layout { monitor: None, ..layout() }), Some(vec2(6400.0, 4800.0))); + // A nonsense screen size is ignored rather than shrinking the window to nothing. + assert_eq!(wrapped_size(&view([400, 300], 1.0), &Layout { monitor: Some(vec2(0.0, f32::NAN)), ..layout() }), Some(vec2(400.0, 300.0))); + // A screen smaller than the minimum still caps the window: it never outgrows the screen. + let tiny = Layout { monitor: Some(vec2(260.0, 190.0)), ..layout() }; + assert_eq!(wrapped_size(&view([400, 300], 1.0), &tiny), Some(vec2(260.0, 190.0) - Vec2::splat(SCREEN_MARGIN))); + assert_eq!(wrapped_size(&view([400, 300], 0.05), &tiny), Some(vec2(260.0, 190.0) - Vec2::splat(SCREEN_MARGIN))); + } + + #[test] + fn a_turned_view_wraps_the_bounding_box() { + let turned = View { rotation: 90.0, ..view([400, 300], 1.0) }; + let size = wrapped_size(&turned, &layout()).unwrap(); + assert!((size - vec2(300.0, 400.0)).length() < 0.01, "{size:?}"); + let turned = View { rotation: 45.0, ..view([400, 400], 1.0) }; + let size = wrapped_size(&turned, &layout()).unwrap(); + assert!((size.x - 400.0 * std::f32::consts::SQRT_2).abs() < 0.01, "{size:?}"); + } + + #[test] + fn nothing_to_wrap_asks_for_nothing() { + assert_eq!(wrapped_size(&view([0, 0], 1.0), &layout()), None, "the view hasn't shown its document yet"); + assert_eq!(wrapped_size(&view([400, 300], 0.0), &layout()), None); + assert_eq!(wrapped_size(&view([400, 300], f32::NAN), &layout()), None); + assert_eq!(wrapped_size(&view([400, 300], 1.0), &Layout { ppp: 0.0, ..layout() }), None); + } + + #[test] + fn only_a_zoom_change_with_the_preference_on_resizes() { + let ctx = Context::default(); + let at = |zoom| view([400, 300], zoom); + // The first frame of a window only records its zoom. + assert_eq!(resize_for(&ctx, 7, true, &at(1.0), &at(1.0), &layout()), None); + // The same zoom again: nothing to do. + assert_eq!(resize_for(&ctx, 7, true, &at(1.0), &at(1.0), &layout()), None); + // Zoom in (the Zoom tool, the wheel, Window › Arrange › Match Zoom): the window follows. + assert_eq!(resize_for(&ctx, 7, true, &at(1.0), &at(2.0), &layout()), Some(vec2(800.0, 600.0))); + assert_eq!(resize_for(&ctx, 7, true, &at(2.0), &at(2.0), &layout()), None, "once per change"); + // A zoom changed outside the canvas (Match Zoom) still counts on the next frame. + assert_eq!(resize_for(&ctx, 7, true, &at(0.5), &at(0.5), &layout()), Some(vec2(240.0, 160.0))); + // Another window keeps its own record. + assert_eq!(resize_for(&ctx, 8, true, &at(0.5), &at(0.5), &layout()), None); + } + + #[test] + fn with_the_preference_off_the_window_keeps_its_size() { + let ctx = Context::default(); + let at = |zoom| view([400, 300], zoom); + assert_eq!(resize_for(&ctx, 1, false, &at(1.0), &at(1.0), &layout()), None); + assert_eq!(resize_for(&ctx, 1, false, &at(1.0), &at(3.0), &layout()), None); + // Turning it on later doesn't catch up on zooms made while it was off. + assert_eq!(resize_for(&ctx, 1, true, &at(3.0), &at(3.0), &layout()), None); + assert_eq!(resize_for(&ctx, 1, true, &at(3.0), &at(1.0), &layout()), Some(vec2(400.0, 300.0))); + } + + #[test] + fn a_fit_never_resizes_the_window() { + let ctx = Context::default(); + let at = |zoom| view([400, 300], zoom); + assert_eq!(resize_for(&ctx, 2, true, &at(1.0), &at(1.0), &layout()), None); + // The fit takes its zoom from the window; resizing for it would start a loop. + let fitting = View { fit_pending: true, ..at(1.0) }; + assert_eq!(resize_for(&ctx, 2, true, &fitting, &at(0.4), &layout()), None); + let filling = View { fill_pending: true, ..at(0.4) }; + assert_eq!(resize_for(&ctx, 2, true, &filling, &at(0.6), &layout()), None); + // The zoom the fit chose is the new baseline. + assert_eq!(resize_for(&ctx, 2, true, &at(0.6), &at(0.6), &layout()), None); + } + + #[test] + fn follow_resizes_only_real_floating_windows_with_the_preference_on() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + let ctx = Context::default(); + let viewport = ViewportId::from_hash_of(("docwin", 3_u64)); + let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); + let at = |zoom| view([400, 300], zoom); + let window = DocWindow { id: 3, document: 0, view: at(1.0), open: true, position: None }; + let frame = |app: &PhotocraftApp, class, after: &View| follow(app, &ctx, class, viewport, &window, &mut after.clone(), rect); + // Off by default, as in Photoshop. + assert!(!app.session.prefs().general.zoom_resizes_windows); + assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), None); + assert_eq!(frame(&app, ViewportClass::Immediate, &at(2.0)), None); + app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap(); + // The scrollbars are drawn over the canvas: the window makes room for them. + assert!(app.ui.view.shows_scrollbars()); + let bars = Vec2::splat(crate::scrollbars::THICKNESS); + assert_eq!(frame(&app, ViewportClass::Immediate, &at(1.0)), Some(vec2(400.0, 300.0) + bars)); + // Drawn inside the main window, a resize command would resize the main window: skipped. + assert_eq!(frame(&app, ViewportClass::EmbeddedWindow, &at(2.0)), None); + // With the rulers on, the window makes room for them. + app.ui.extras.rulers = true; + let ruled = frame(&app, ViewportClass::Immediate, &at(2.0)).unwrap(); + assert!(ruled.x > 800.0 && ruled.y > 600.0, "{ruled:?}"); + } + + /// Where the image lands in a window of the wrapped size, and the canvas area the scrollbars + /// leave free, both in points from the window's top-left. + fn landing(view: &View, layout: &Layout) -> (Rect, Rect) { + let size = wrapped_size(view, layout).unwrap(); + let canvas = Rect::from_min_max(Pos2::ZERO + layout.chrome, Pos2::ZERO + size); + let free = Rect::from_min_max(canvas.min, canvas.max - Vec2::splat(layout.bars)); + let xf = crate::canvas::ViewXform { + rect: canvas, + zoom: view.zoom / layout.ppp, + center: view.center, + flip: layout.flip, + rotation: view.rotation, + aspect: layout.aspect, + }; + (xf.image_bounds(view.doc_size), free) + } + + fn near(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 0.01 && (a.max - b.max).length() < 0.01 + } + + #[test] + fn the_window_makes_room_for_the_scrollbars() { + let barred = Layout { bars: 12.0, chrome: vec2(16.0, 16.0), ..layout() }; + assert_eq!(wrapped_size(&view([400, 300], 1.0), &barred), Some(vec2(428.0, 328.0))); + } + + #[test] + fn a_wrapped_window_shows_the_whole_image_centred() { + // Zoomed in about the top-left corner: the view centre is far from the image's middle. + let zoomed = View { center: [60.0, 40.0], ..view([400, 300], 2.0) }; + for layout in [ + layout(), + Layout { bars: 12.0, chrome: vec2(16.0, 16.0), ..layout() }, + Layout { bars: 12.0, flip: true, ..layout() }, + Layout { bars: 12.0, ppp: 2.0, aspect: 1.5, ..layout() }, + ] { + for rotation in [0.0, 90.0, -30.0] { + let mut v = View { rotation, ..zoomed.clone() }; + let (before, free) = landing(&v, &layout); + assert!(!near(before, free), "{layout:?} {rotation}: off centre before"); + v.center = wrapped_center(&v, &layout).unwrap(); + let (after, free) = landing(&v, &layout); + assert!(near(after, free), "{layout:?} {rotation}: {after:?} fills {free:?}"); + } + } + // Zoomed far out the window keeps its minimum size, and the image sits in its middle. + let small = View { center: [0.0, 0.0], ..view([400, 300], 0.1) }; + let c = wrapped_center(&small, &layout()).unwrap(); + assert!((c[0] - 200.0).abs() < 0.01 && (c[1] - 150.0).abs() < 0.01, "{c:?}"); + } + + #[test] + fn a_window_the_screen_caps_keeps_the_zoomed_spot() { + let zoomed = View { center: [60.0, 40.0], ..view([400, 300], 16.0) }; + assert_eq!(wrapped_center(&zoomed, &layout()), None); + } + + #[test] + fn follow_centres_the_image_in_the_grown_window() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap(); + let ctx = Context::default(); + let viewport = ViewportId::from_hash_of(("docwin", 4_u64)); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let window = DocWindow { id: 4, document: 0, view: view([400, 300], 1.0), open: true, position: None }; + let mut first = view([400, 300], 1.0); + assert_eq!(follow(&app, &ctx, ViewportClass::Immediate, viewport, &window, &mut first, rect), None); + // A Zoom tool click near the top-left corner doubles the zoom about that spot. + let mut zoomed = View { center: [60.0, 40.0], ..view([400, 300], 2.0) }; + assert!(follow(&app, &ctx, ViewportClass::Immediate, viewport, &window, &mut zoomed, rect).is_some()); + let half_bar = crate::scrollbars::THICKNESS / 2.0 / 2.0; + assert_eq!(zoomed.center, [200.0 + half_bar, 150.0 + half_bar], "the whole image, centred"); + assert_eq!(zoomed.zoom, 2.0); + } + + #[test] + fn a_maximized_or_full_screen_window_keeps_its_size() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.run("prefs.set", json!({"path": "general.zoomResizesWindows", "value": true})).unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let window = DocWindow { id: 5, document: 0, view: view([400, 300], 1.0), open: true, position: None }; + for (maximized, fullscreen) in [(true, false), (false, true)] { + let ctx = Context::default(); + let mut raw = egui::RawInput::default(); + let info = egui::ViewportInfo { maximized: Some(maximized), fullscreen: Some(fullscreen), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, info); + let viewport = ViewportId::ROOT; + let mut sizes = Vec::new(); + let frame = |raw: egui::RawInput, zoom: f32, sizes: &mut Vec>| { + let mut out = ctx.run_ui(raw, |ui| { + let mut v = View { center: [60.0, 40.0], ..view([400, 300], zoom) }; + sizes.push(follow(&app, ui.ctx(), ViewportClass::Immediate, viewport, &window, &mut v, rect)); + assert_eq!(v.center, [60.0, 40.0]); + }); + out.textures_delta.clear(); + }; + let mut normal = egui::RawInput::default(); + normal.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { maximized: Some(false), fullscreen: Some(false), ..Default::default() }); + frame(normal.clone(), 1.0, &mut sizes); + frame(raw, 2.0, &mut sizes); + // Back to a normal window: the zoom made meanwhile is not caught up on. + frame(normal, 2.0, &mut sizes); + assert_eq!(sizes, [None, None, None], "maximized {maximized}, full screen {fullscreen}"); + } + } + + #[test] + fn the_zoom_tool_options_bar_shows_the_same_setting() { + // Photoshop's Zoom tool option Resize Windows to Fit is Preferences › Zoom Resizes Windows. + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Zoom; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + assert!(!h.state().session.prefs().general.zoom_resizes_windows); + h.get_by_label("Resize Windows to Fit").click(); + h.run_steps(2); + assert!(h.state().session.prefs().general.zoom_resizes_windows, "the option turns the preference on"); + h.get_by_label("Resize Windows to Fit").click(); + h.run_steps(2); + assert!(!h.state().session.prefs().general.zoom_resizes_windows, "and off again"); + } +} diff --git a/crates/ui-egui/tests/adjust_preview_gpu.rs b/crates/ui-egui/tests/it/adjust_preview_gpu.rs similarity index 98% rename from crates/ui-egui/tests/adjust_preview_gpu.rs rename to crates/ui-egui/tests/it/adjust_preview_gpu.rs index 82d47596fc..d3880b04fd 100644 --- a/crates/ui-egui/tests/adjust_preview_gpu.rs +++ b/crates/ui-egui/tests/it/adjust_preview_gpu.rs @@ -1,7 +1,7 @@ //! GPU parity of the Image › Adjustments dialog preview (`adjust_preview`): the document with //! the temporary clipped adjustment layer must composite on the wgpu compositor like on the CPU //! reference, and like the destructive command's result, for every kind with a dialog. Skips -//! without an adapter that renders 32-bit float targets (like `crates/gpu/tests/parity.rs`). +//! without an adapter that renders 32-bit float targets (like `crates/gpu/tests/it/parity.rs`). use eframe::wgpu; use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; @@ -126,6 +126,7 @@ fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]], skip: usize) -> f32 { #[test] fn preview_layer_composites_on_the_gpu_like_the_command() { + let _gpu = photocraft_testkit::gpu_lock(); let Some((device, queue, mut comp)) = gpu() else { return }; // GPU vs CPU of the same document (as the compositor parity tests), and GPU preview vs the // command's CPU result (adds the command's rounding to the layer depth). @@ -170,6 +171,7 @@ fn preview_layer_composites_on_the_gpu_like_the_command() { /// The zoomed-out proxy preview (own document and layer ids) composites on the GPU like the CPU. #[test] fn proxy_preview_composites_on_the_gpu() { + let _gpu = photocraft_testkit::gpu_lock(); use photocraft_ui_egui::adjust_preview::{base_document, proxy_base, proxy_with_settings}; let Some((device, queue, mut comp)) = gpu() else { return }; for selection in [false, true] { diff --git a/crates/ui-egui/tests/arbitrary_rotation_preview.rs b/crates/ui-egui/tests/it/arbitrary_rotation_preview.rs similarity index 100% rename from crates/ui-egui/tests/arbitrary_rotation_preview.rs rename to crates/ui-egui/tests/it/arbitrary_rotation_preview.rs diff --git a/crates/ui-egui/tests/batch_export_options.rs b/crates/ui-egui/tests/it/batch_export_options.rs similarity index 100% rename from crates/ui-egui/tests/batch_export_options.rs rename to crates/ui-egui/tests/it/batch_export_options.rs diff --git a/crates/ui-egui/tests/camera_raw_histogram.rs b/crates/ui-egui/tests/it/camera_raw_histogram.rs similarity index 100% rename from crates/ui-egui/tests/camera_raw_histogram.rs rename to crates/ui-egui/tests/it/camera_raw_histogram.rs diff --git a/crates/ui-egui/tests/camera_raw_localization.rs b/crates/ui-egui/tests/it/camera_raw_localization.rs similarity index 100% rename from crates/ui-egui/tests/camera_raw_localization.rs rename to crates/ui-egui/tests/it/camera_raw_localization.rs diff --git a/crates/ui-egui/tests/camera_raw_scope.rs b/crates/ui-egui/tests/it/camera_raw_scope.rs similarity index 100% rename from crates/ui-egui/tests/camera_raw_scope.rs rename to crates/ui-egui/tests/it/camera_raw_scope.rs diff --git a/crates/ui-egui/tests/camera_raw_zoom.rs b/crates/ui-egui/tests/it/camera_raw_zoom.rs similarity index 100% rename from crates/ui-egui/tests/camera_raw_zoom.rs rename to crates/ui-egui/tests/it/camera_raw_zoom.rs diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/it/canvas_16f.rs similarity index 100% rename from crates/ui-egui/tests/canvas_16f.rs rename to crates/ui-egui/tests/it/canvas_16f.rs diff --git a/crates/ui-egui/tests/checker_canvas.rs b/crates/ui-egui/tests/it/checker_canvas.rs similarity index 99% rename from crates/ui-egui/tests/checker_canvas.rs rename to crates/ui-egui/tests/it/checker_canvas.rs index e626b42128..3c5e061afd 100644 --- a/crates/ui-egui/tests/checker_canvas.rs +++ b/crates/ui-egui/tests/it/checker_canvas.rs @@ -278,6 +278,7 @@ fn grid_line_contrast_over_grey(h: &mut Harness, grey: u8) -> i32 { #[test] fn pixel_grid_does_not_flip_colour_across_mid_tones() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; h.run_steps(4); // 0x87 and 0x90 sit either side of luma 0.55, where the line used to switch from a full white diff --git a/crates/ui-egui/tests/color_managed_canvas.rs b/crates/ui-egui/tests/it/color_managed_canvas.rs similarity index 98% rename from crates/ui-egui/tests/color_managed_canvas.rs rename to crates/ui-egui/tests/it/color_managed_canvas.rs index 4267a2cf53..ec58a4faa1 100644 --- a/crates/ui-egui/tests/color_managed_canvas.rs +++ b/crates/ui-egui/tests/it/color_managed_canvas.rs @@ -1,7 +1,7 @@ //! The canvas is colour-managed (#46): a Display P3 document shows its colours converted to //! the (sRGB) monitor on the GPU canvas (display LUT) and on the CPU canvas (flipped views draw //! through the CPU path); sRGB documents are shown unchanged. Skips when no GPU adapter with -//! 32-bit float render targets exists (like `crates/gpu/tests/parity.rs`). +//! 32-bit float render targets exists (like `crates/gpu/tests/it/parity.rs`). use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/ui-egui/tests/crop_beyond_canvas.rs b/crates/ui-egui/tests/it/crop_beyond_canvas.rs similarity index 100% rename from crates/ui-egui/tests/crop_beyond_canvas.rs rename to crates/ui-egui/tests/it/crop_beyond_canvas.rs diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/it/crop_default_mode.rs similarity index 100% rename from crates/ui-egui/tests/crop_default_mode.rs rename to crates/ui-egui/tests/it/crop_default_mode.rs diff --git a/crates/ui-egui/tests/crop_popup_keys.rs b/crates/ui-egui/tests/it/crop_popup_keys.rs similarity index 95% rename from crates/ui-egui/tests/crop_popup_keys.rs rename to crates/ui-egui/tests/it/crop_popup_keys.rs index 9c893ae7e0..facb1be24a 100644 --- a/crates/ui-egui/tests/crop_popup_keys.rs +++ b/crates/ui-egui/tests/it/crop_popup_keys.rs @@ -55,6 +55,9 @@ fn setup() -> Option { h.run_steps(3); h.get_by_label("1 : 1 (Square)").click(); h.run_steps(3); + // Picking a ratio only fits the untouched default frame; a keyed edit makes it a drawn one. + assert!(photocraft_ui_egui::crop_ui::nudge(h.state_mut(), 0.0, 0.0)); + h.run_steps(3); assert!(photocraft_ui_egui::crop_ui::pending(h.state()), "the 1:1 frame is pending"); assert!(!egui::Popup::is_any_open(&h.ctx), "the list closed after the pick"); h.get_by_value("1 : 1 (Square)").click(); diff --git a/crates/ui-egui/tests/custom_print_paper.rs b/crates/ui-egui/tests/it/custom_print_paper.rs similarity index 55% rename from crates/ui-egui/tests/custom_print_paper.rs rename to crates/ui-egui/tests/it/custom_print_paper.rs index d0d81dea69..4aa53eb96d 100644 --- a/crates/ui-egui/tests/custom_print_paper.rs +++ b/crates/ui-egui/tests/it/custom_print_paper.rs @@ -1,6 +1,9 @@ //! #3820: edit custom paper through the real Print dialog, preview it, and print safely to PDF. use egui::{Event, Key, Modifiers, PointerButton, vec2}; -use egui_kittest::{Harness, kittest::Queryable}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; use photocraft_ui_egui::{PhotocraftApp, file_ui::open_print}; use serde_json::{Map, Value, json}; @@ -22,7 +25,13 @@ fn show(ui: &mut egui::Ui, app: &mut PhotocraftApp) { } fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { - let mut h = Harness::builder().with_size(vec2(1000.0, 900.0)).with_max_steps(64).build_ui_state(show, app); + harness_at_scale(app, 1.0) +} + +/// Builds the real dialog at an explicit physical-pixels-per-egui-point ratio so pointer +/// events remain in egui points while exercising both standard and Retina display scales. +fn harness_at_scale(app: PhotocraftApp, pixels_per_point: f32) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1000.0, 900.0)).with_pixels_per_point(pixels_per_point).with_max_steps(64).build_ui_state(show, app); PhotocraftApp::setup_context(&h.ctx, Default::default()); h.run_steps(4); h @@ -42,8 +51,8 @@ fn shown(h: &Harness<'_, PhotocraftApp>, text: &str) -> bool { } fn enter_length(h: &mut Harness<'_, PhotocraftApp>, label: &str, value: &str) { - // Anchor the pointer to the real label; the existing length widget immediately follows it. - let pos = h.get_by_label(label).rect().right_center() + vec2(35.0, 0.0); + // Use the editor's actual accessibility bounds rather than a fixed offset from its label. + let pos = h.get_by_label(label).parent().unwrap().get_by_role(egui::accesskit::Role::SpinButton).rect().center(); h.event(Event::PointerMoved(pos)); h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.run_steps(1); @@ -60,6 +69,89 @@ fn fields(h: &Harness<'_, PhotocraftApp>, id: u64) -> Map { h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone() } +/// Finds the thumbnail's painted mesh bounds, rather than the earlier interaction bounds, +/// so drag assertions detect visible lag in the same frame as a Center transition. +fn painted_image(h: &Harness<'_, PhotocraftApp>) -> egui::Rect { + let active = h.state().session.active().unwrap(); + let key = egui::Id::new(("print-thumb", active.doc.id.0, active.revision)); + let tex = h.ctx.data(|d| d.get_temp::>(key)).unwrap(); + h.output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id == tex.id() => Some(mesh.calc_bounds()), + _ => None, + }) + .expect("the print image must be painted") +} + +/// Exercises live painted movement at 1x/2x display scale, signed numeric entry and pointer +/// capture off-page and back, then verifies Center restores the shared layout's placement. +#[test] +fn preview_drag_and_negative_fields_work_at_standard_and_retina_scale() { + for pixels_per_point in [1.0, 2.0] { + let (mut app, id) = app(); + app.ui + .dialog_mut(id) + .unwrap() + .fields + .extend(json!({"paper": "a4", "center": true, "scale": 100, "scaleToFit": false, "units": "cm"}).as_object().unwrap().clone()); + let mut h = harness_at_scale(app, pixels_per_point); + let start = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(fields(&h, id)), "file.print").unwrap(); + let start_image = painted_image(&h); + let from = start_image.center(); + let delta = vec2(-24.0, 18.0); + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=3 { + h.event(Event::PointerMoved(from + delta * (step as f32 / 3.0))); + h.run_steps(1); + } + let during = fields(&h, id); + assert_eq!(during["center"], false, "Center must switch off during the drag"); + // The hit target was registered before this frame's pointer handling. Inspect the + // actual painted mesh to verify the image moves immediately, without a frame of lag. + let moved_image = painted_image(&h); + assert!( + (moved_image.min - start_image.min - delta).length() < 0.1, + "the visible image must stay under the pointer when Center turns off: {start_image:?} -> {moved_image:?}, delta {delta:?}" + ); + let k = (310.0 / start.paper.0).min(380.0 / start.paper.1); + assert!((during["left"].as_f64().unwrap() * 72.0 - start.rect.0 - f64::from(delta.x) / k).abs() < 0.001); + assert!((during["top"].as_f64().unwrap() * 72.0 - (start.paper.1 - start.rect.1 - start.rect.3) - f64::from(delta.y) / k).abs() < 0.001); + h.event(Event::PointerButton { pos: from + delta, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + click(&mut h, "Centimeters"); + click(&mut h, "Inches"); + enter_length(&mut h, "Top", "-0.25"); + enter_length(&mut h, "Left", "-0.5"); + let negative = fields(&h, id); + assert_eq!(negative["top"], -0.25); + assert_eq!(negative["left"], -0.5); + let visible = h.get_by_label("Print preview image").rect(); + assert!(visible.is_positive(), "a partly off-page image must retain a visible drag target"); + let from = visible.center(); + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + // Keep capture even when both the pointer and image leave the sheet entirely. + h.event(Event::PointerMoved(from - vec2(400.0, 0.0))); + h.run_steps(2); + assert!(fields(&h, id)["left"].as_f64().unwrap() < -1.0); + h.event(Event::PointerMoved(from + vec2(12.0, 12.0))); + h.run_steps(2); + assert!(fields(&h, id)["left"].as_f64().unwrap() > -0.5); + h.event(Event::PointerButton { pos: from + vec2(12.0, 12.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + click(&mut h, "Center"); + let centered = photocraft_engine::print_cmds::layout(&h.state().session.active().unwrap().doc, &Value::Object(fields(&h, id)), "file.print").unwrap(); + assert_eq!(fields(&h, id)["center"], true); + assert!((centered.rect.0 - (centered.paper.0 - centered.rect.2) / 2.0).abs() < 1e-9); + assert!((centered.rect.1 - (centered.paper.1 - centered.rect.3) / 2.0).abs() < 1e-9); + } +} + #[test] fn custom_paper_ui_edits_reach_preview_and_pdf_and_restore_when_reopened() { let (mut app, id) = app(); diff --git a/crates/ui-egui/tests/display_shadows.rs b/crates/ui-egui/tests/it/display_shadows.rs similarity index 100% rename from crates/ui-egui/tests/display_shadows.rs rename to crates/ui-egui/tests/it/display_shadows.rs diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/it/drag_preview_canvas.rs similarity index 100% rename from crates/ui-egui/tests/drag_preview_canvas.rs rename to crates/ui-egui/tests/it/drag_preview_canvas.rs diff --git a/crates/ui-egui/tests/export_as_scaling.rs b/crates/ui-egui/tests/it/export_as_scaling.rs similarity index 63% rename from crates/ui-egui/tests/export_as_scaling.rs rename to crates/ui-egui/tests/it/export_as_scaling.rs index f1c9cba32c..c984f11447 100644 --- a/crates/ui-egui/tests/export_as_scaling.rs +++ b/crates/ui-egui/tests/it/export_as_scaling.rs @@ -129,3 +129,82 @@ fn export_as_scales_a_narrow_image_without_editing_the_source() { assert_eq!(st.revision, revision, "export must not change source revision"); } } + +/// #5838: Export As › Image Size › Resample. Choose Nearest Neighbor in the real dropdown, export +/// at 200 %, and every source pixel arrives as an exact 2 × 2 block; the choice is remembered. +#[test] +fn export_as_resample_menu_writes_nearest_neighbor_blocks() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut h, writes)) = harness() else { return }; + h.run_steps(4); + { + let app = h.state_mut(); + app.run("file.new", json!({"width": 4, "height": 3, "depth": 8, "background": "white"})).expect("new document"); + app.session + .edit("checker", |doc, active| { + let b = doc.bounds(); + let layer = (*active).ok_or(photocraft_engine::EngineError::NoDocument)?; + let surf = doc.layer_mut(layer).and_then(|l| l.surface_mut()).ok_or(photocraft_engine::EngineError::NoDocument)?; + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + data.extend(photocraft_raster::from_rgba(&fmt, if (x + y) % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] })); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .expect("paint a checkerboard"); + app.sync_views(); + } + let source = h.state().session.active().expect("document").doc.clone(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "file.export.exportAs", json!({})).expect("open Export As"); + h.run_steps(6); + + let label = h.get_by_label("Resample").rect(); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("Resample dropdown beside its label") + .click(); + h.run_steps(3); + h.get_by_label("Nearest Neighbor").click(); + h.run_steps(3); + + let label = h.get_by_label("Scale").rect(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && r.left() < label.right() + 100.0 && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("Scale numeric field") + .click(); + h.run_steps(2); + h.event(egui::Event::Text("200".into())); + h.run_steps(3); + h.get_by_label("Image Size").click(); + h.run_steps(3); + assert!(h.query_by_label("8 × 6 px").is_some(), "dialog shows the requested output size"); + evidence(&mut h, 8, "resample-dialog"); + + h.get_by_label("Export").click(); + h.run_steps(6); + let written = writes.borrow(); + assert_eq!(written.len(), 1, "Export writes one file"); + let png = photocraft_io::import("out.png", &written[0].1).expect("decode the written PNG").document; + assert_eq!((png.size.width, png.size.height), (8, 6)); + let surf = png.layers[0].surface().expect("pixels"); + for y in 0..6 { + for x in 0..8 { + 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, "pixel {x},{y}"); + } + } + let st = h.state().session.active().expect("source remains open"); + assert!(Arc::ptr_eq(&st.doc, &source), "the source document is unchanged"); + assert_eq!(h.state().session.prefs().dialogs["file.export.exportAs"]["resample"], "nearest", "the choice is remembered"); +} diff --git a/crates/ui-egui/tests/full_res_preview.rs b/crates/ui-egui/tests/it/full_res_preview.rs similarity index 100% rename from crates/ui-egui/tests/full_res_preview.rs rename to crates/ui-egui/tests/it/full_res_preview.rs diff --git a/crates/ui-egui/tests/gpu_device_loss.rs b/crates/ui-egui/tests/it/gpu_device_loss.rs similarity index 56% rename from crates/ui-egui/tests/gpu_device_loss.rs rename to crates/ui-egui/tests/it/gpu_device_loss.rs index ed9626ee2e..37dd3db267 100644 --- a/crates/ui-egui/tests/gpu_device_loss.rs +++ b/crates/ui-egui/tests/it/gpu_device_loss.rs @@ -219,3 +219,144 @@ fn canvas_over_the_texture_budget_builds_no_textures() { assert!(g.texture_info(key).is_none()); g.release(); } + +/// #3438, #1215: when the window's own device is really lost (a driver reset, for example after +/// the PC slept), egui-wgpu can't draw another frame. One with shapes panicked inside it ("Failed +/// to create staging buffer for index data"), and a frozen window's close button opened an +/// unsaved-changes prompt nobody could see. Now the app draws nothing, writes the unsaved document +/// for recovery and closes. Runs the real frame loop on the harness's own device. +#[test] +fn a_lost_window_device_saves_unsaved_work_for_recovery_and_closes() { + let _gpu = gpu_lock(); + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (autosaves, flushes) = (log.clone(), log.clone()); + let render_state = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let window = render_state.clone(); + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_max_steps(16).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let services = photocraft_ui_egui::Services { + autosave: Some(Box::new(move |doc: &std::sync::Arc, revision: u64, _: Option<&str>| { + autosaves.lock().unwrap().push(format!("autosave {} {revision}", doc.id.0)); + Ok(()) + })), + flush_autosaves: Some(Box::new(move || { + flushes.lock().unwrap().push("flush".into()); + Vec::new() + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + *window.lock().unwrap() = Some(rs.clone()); + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }) + })); + // Upload and submit the last frame's shapes the way the window's renderer does each frame: on + // a lost device, egui-wgpu panics here when there is anything to draw (#1215). + let upload = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let rs = render_state.lock().unwrap().clone().expect("the harness renders with wgpu"); + let output = h.output(); + let jobs = h.ctx.tessellate(output.shapes.clone(), output.pixels_per_point); + let screen = eframe::egui_wgpu::ScreenDescriptor { size_in_pixels: [1000, 700], pixels_per_point: output.pixels_per_point }; + let mut encoder = rs.device.create_command_encoder(&Default::default()); + let uploaded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let commands = rs.renderer.write().update_buffers(&rs.device, &rs.queue, &mut encoder, &jobs, &screen); + rs.queue.submit(commands.into_iter().chain([encoder.finish()])); + })); + (jobs.len(), uploaded.is_ok()) + }; + let Ok(mut h) = built else { + eprintln!("skipping: no GPU adapter"); + return; + }; + h.run_steps(4); + let (jobs, uploaded) = upload(&h); + assert!(jobs > 0 && uploaded, "draws before the loss"); + let (doc, revision, dirty) = h.state().session.active().map(|d| (d.doc.id.0, d.revision, d.is_dirty())).unwrap(); + assert!(dirty, "the new layer is unsaved"); + assert!(log.lock().unwrap().is_empty(), "no autosave before the loss"); + assert!(h.state_mut().destroy_window_device()); + // wgpu reports a destroyed device lost once the work submitted before finished (slow on a + // software adapter); a driver reset reports it at once. + if let Some(rs) = render_state.lock().unwrap().as_ref() { + let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(30)) }); + } + h.run_steps(2); + assert!(h.state().window_lost(), "the loss was noticed"); + assert_eq!(*log.lock().unwrap(), vec![format!("autosave {doc} {revision}"), "flush".to_string()], "saved for recovery once"); + // The frame on the lost device has nothing to draw, so egui-wgpu has nothing to upload. + assert_eq!(upload(&h), (0, true), "a frame with shapes panics inside egui-wgpu on a lost device"); + assert_eq!(h.state().session.documents().len(), 1, "the document stays until the app closes"); +} + +/// #6464 review: when the recovery save fails (a full disk, say), closing would lose the work, so +/// the app stays open with the document and tries again until the write succeeds, then closes. +#[test] +fn a_lost_window_device_closes_only_once_unsaved_work_is_saved() { + let _gpu = gpu_lock(); + let disk_full = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (full, tries) = (disk_full.clone(), attempts.clone()); + let pending = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(u64, u64)>::new())); + let queued = pending.clone(); + let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_max_steps(16).wgpu().build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let services = photocraft_ui_egui::Services { + autosave: Some(Box::new(move |doc: &std::sync::Arc, revision: u64, _: Option<&str>| { + queued.lock().unwrap().push((doc.id.0, revision)); + Ok(()) + })), + autosave_results: Some(Box::new(Vec::new)), + flush_autosaves: Some(Box::new(move || { + tries.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let outcome = if full.load(std::sync::atomic::Ordering::SeqCst) { Err("disk full".to_string()) } else { Ok(()) }; + pending.lock().unwrap().drain(..).map(|(id, revision)| (id, revision, outcome.clone())).collect() + })), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"width": 320, "height": 240})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }) + })); + let Ok(mut h) = built else { + eprintln!("skipping: no GPU adapter"); + return; + }; + h.run_steps(4); + assert!(h.state_mut().destroy_window_device()); + for _ in 0..120 { + h.run_steps(1); + if h.state().window_lost() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + assert!(h.state().window_lost(), "the loss was noticed"); + let tries = || attempts.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(tries(), 1); + assert!(h.state().has_unsaved_work(), "a failed recovery save doesn't let the app close"); + // Each step is 0.25 s: no new attempt before the retry interval. + h.run_steps(4); + assert_eq!(tries(), 1, "waits before trying again"); + h.run_steps(5); + assert_eq!(tries(), 2, "tries again"); + assert!(h.state().has_unsaved_work()); + disk_full.store(false, std::sync::atomic::Ordering::SeqCst); + h.run_steps(9); + assert_eq!(tries(), 3); + assert!(!h.state().has_unsaved_work(), "closes once the work is saved"); + h.run_steps(9); + assert_eq!(tries(), 3, "saved once, not again"); + assert_eq!(h.state().session.documents().len(), 1, "the document stays until the app closes"); +} diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/it/layout_perf.rs similarity index 99% rename from crates/ui-egui/tests/layout_perf.rs rename to crates/ui-egui/tests/it/layout_perf.rs index fb17833e18..2b5cf73bf1 100644 --- a/crates/ui-egui/tests/layout_perf.rs +++ b/crates/ui-egui/tests/it/layout_perf.rs @@ -4,7 +4,7 @@ //! nothing and hiding one recomposites only its area; and the GPU compositor (which composites //! small layers over their bounds only) matches the CPU reference on the whole document. -#[path = "support/layout_doc.rs"] +#[path = "../support/layout_doc.rs"] mod layout_doc; use egui::{Event, Modifiers, PointerButton, Pos2}; @@ -220,6 +220,7 @@ fn the_move_preview_is_what_the_move_commits() { #[test] fn layout_document_composites_on_the_gpu_like_the_cpu() { + let _gpu = photocraft_testkit::gpu_lock(); use eframe::wgpu; fn block_on(f: F) -> F::Output { let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); diff --git a/crates/ui-egui/tests/live_stroke_canvas.rs b/crates/ui-egui/tests/it/live_stroke_canvas.rs similarity index 100% rename from crates/ui-egui/tests/live_stroke_canvas.rs rename to crates/ui-egui/tests/it/live_stroke_canvas.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 0000000000..a65e094315 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,33 @@ +//! The crate's integration tests, linked into one test binary. + +mod adjust_preview_gpu; +mod arbitrary_rotation_preview; +mod batch_export_options; +mod camera_raw_histogram; +mod camera_raw_localization; +mod camera_raw_scope; +mod camera_raw_zoom; +mod canvas_16f; +mod checker_canvas; +mod color_managed_canvas; +mod crop_beyond_canvas; +mod crop_default_mode; +mod crop_popup_keys; +mod custom_print_paper; +mod display_shadows; +mod drag_preview_canvas; +mod export_as_scaling; +mod full_res_preview; +mod gpu_device_loss; +mod layout_perf; +mod live_stroke_canvas; +mod marching_ants_pixels; +mod marquee_autoscroll; +mod paint_bucket_all_layers; +mod pattern_preview_canvas; +mod radial_blur_center; +mod remove_channel_target; +mod system_locale; +mod ui_scale_canvas; +mod variables_data_sets; +mod workspace_presets; diff --git a/crates/ui-egui/tests/it/marching_ants_pixels.rs b/crates/ui-egui/tests/it/marching_ants_pixels.rs new file mode 100644 index 0000000000..c790050612 --- /dev/null +++ b/crates/ui-egui/tests/it/marching_ants_pixels.rs @@ -0,0 +1,208 @@ +//! #7489: the marching ants are drawn in device pixels, the same on every edge, at any display +//! scale, as Photoshop draws them. +//! +//! The ants were snapped to the middle of an egui point and drawn one point wide. At 125 % or +//! 150 % a point is not a whole number of pixels, so one edge of a rectangle came out 2 px wide +//! and the next 1 px, and the dashes 5 px long here and 4 px there. Photoshop draws them on the +//! screen's own pixels: black and white dashes, every edge as thick as every other, thicker on a +//! scaled display, the dash pattern running diagonally across the screen so a 2 px line's dashes +//! end on a slant. +//! +//! These tests render a committed rectangular selection with the real app offscreen on wgpu and +//! read the pixels along each edge. They skip when no GPU adapter exists. + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +/// A rendered frame: RGBA8, physical pixels. +struct Image { + w: u32, + h: u32, + px: Vec, +} + +impl Image { + /// Luma of pixel `(x, y)`, or `None` off the frame. + fn luma(&self, x: i64, y: i64) -> Option { + if x < 0 || y < 0 || x >= i64::from(self.w) || y >= i64::from(self.h) { + return None; + } + let i = (y as usize * self.w as usize + x as usize) * 4; + let p = self.px.get(i..i + 3)?; + Some(((u16::from(p[0]) * 3 + u16::from(p[1]) * 6 + u16::from(p[2])) / 10) as u8) + } +} + +/// The app on an offscreen wgpu harness, or `None` when no GPU adapter exists. Only a panic +/// before the app callback runs (creating the adapter) is a skip; one from the app's own setup +/// fails the test rather than passing it untested. +fn harness(ppp: f32) -> Option { + let started = Arc::new(AtomicBool::new(false)); + let started_in = Arc::clone(&started); + let built = std::panic::catch_unwind(move || { + egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_pixels_per_point(ppp).with_max_steps(64).wgpu().build_eframe(move |cc| { + started_in.store(true, Ordering::SeqCst); + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(payload) if started.load(Ordering::SeqCst) => std::panic::resume_unwind(payload), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A fresh 400 × 300 document in `background` at 100 % with the rectangle `r` selected. +fn setup(h: &mut Harness, background: &str, r: [i32; 4]) { + { + let app = h.state_mut(); + while app.session.active().is_some() { + app.run("file.close", json!({"discard": true})).expect("close"); + } + app.run("file.new", json!({"width": 400, "height": 300, "background": background})).expect("new"); + app.sync_views(); + app.ui.extras.rulers = false; + app.run("select.rect", json!({"x": r[0], "y": r[1], "width": r[2] - r[0], "height": r[3] - r[1]})).expect("select"); + } + control(h, "ui.set", json!({"tool": "rectMarquee", "zoom": 1.0, "center": [200, 150]})); + h.run_steps(3); +} + +fn render(h: &mut Harness) -> Image { + let img = h.render().expect("render"); + Image { w: img.width(), h: img.height(), px: img.into_raw() } +} + +/// Device pixel of document point `(x, y)`. +fn device(h: &Harness, ppp: f32, x: f32, y: f32) -> (i64, i64) { + let p = ViewXform::active(h.state()).expect("an active document view").to_screen(x, y) * ppp; + (p.x.round() as i64, p.y.round() as i64) +} + +/// One edge of the outline as seen on screen: for every device pixel along its middle, the +/// pixels across it (±6) that are dark. +struct Edge { + /// Dark pixels across the edge at each step along it. + across: Vec>, + /// Pixels in the band that are neither black nor white (antialiasing grey). + greys: usize, +} + +/// Scan the edge from device pixel `a` to `b` (one of x or y shared), skipping 12 px at each end. +fn scan(img: &Image, a: (i64, i64), b: (i64, i64)) -> Edge { + let vertical = a.0 == b.0; + let (lo, hi) = if vertical { (a.1.min(b.1), a.1.max(b.1)) } else { (a.0.min(b.0), a.0.max(b.0)) }; + let mut across = Vec::new(); + let mut greys = 0; + for t in lo + 12..hi - 12 { + let mut dark = Vec::new(); + for o in -6..=6 { + let (x, y) = if vertical { (a.0 + o, t) } else { (t, a.1 + o) }; + let l = img.luma(x, y).unwrap_or(255); + if l < 64 { + dark.push(o); + } + if (24..232).contains(&l) { + greys += 1; + } + } + across.push(dark); + } + Edge { across, greys } +} + +/// The line is `w` px thick on every edge, crisp, with dashes of 4·w px. +#[test] +fn every_edge_is_equally_thick_at_any_display_scale() { + let _gpu = photocraft_testkit::gpu_lock(); + for ppp in [1.0f32, 1.25, 1.5, 2.0] { + let Some(mut h) = harness(ppp) else { return }; + h.run_steps(4); + // Edges at odd and even document columns, so a fractional scale lands them at different + // fractions of a pixel. + let r = [101, 60, 203, 241]; + setup(&mut h, "white", r); + let img = render(&mut h); + let w = ppp.ceil() as usize; + let [x0, y0, x1, y1] = r.map(|v| v as f32); + let (tl, tr, br, bl) = (device(&h, ppp, x0, y0), device(&h, ppp, x1, y0), device(&h, ppp, x1, y1), device(&h, ppp, x0, y1)); + let sides = [("top", (tl, tr)), ("right", (tr, br)), ("bottom", (bl, br)), ("left", (tl, bl))]; + let edges: Vec<(&str, Edge)> = sides.iter().map(|&(name, (a, b))| (name, scan(&img, a, b))).collect(); + // The bug: at 125 % one edge came out 2 px thick and the next 1 px. + let thickest: Vec<(&str, usize)> = edges.iter().map(|(name, e)| (*name, e.across.iter().map(Vec::len).max().unwrap_or(0))).collect(); + assert!(thickest.iter().all(|t| t.1 == thickest[0].1), "@{ppp}x: every edge as thick as the others: {thickest:?}"); + for (name, e) in edges { + assert_eq!(e.greys, 0, "@{ppp}x {name}: the ants are crisp black and white"); + let thick: Vec = e.across.iter().map(Vec::len).filter(|&n| n > 0).collect(); + assert!(!thick.is_empty(), "@{ppp}x {name}: the edge shows"); + // Where a dash covers every row across the line it is exactly `w` thick; at a dash's + // slanted end only some of them are dark. + let max = thick.iter().copied().max().unwrap_or(0); + assert_eq!(max, w, "@{ppp}x {name}: {w} px thick, got {thick:?}"); + // Half the line is black: dashes and gaps of 4·w px each. + let dark: usize = e.across.iter().map(Vec::len).sum(); + let frac = dark as f32 / (e.across.len() * w) as f32; + assert!((0.45..=0.55).contains(&frac), "@{ppp}x {name}: {frac:.2} of the line is black"); + // Along any one row of the line, black runs are 4·w px long. + let rows: std::collections::BTreeSet = e.across.iter().flatten().copied().collect(); + for row in rows { + let on: Vec = e.across.iter().map(|d| d.contains(&row)).collect(); + let mut runs = Vec::new(); + let mut n = 0; + for (i, &b) in on.iter().enumerate() { + if b { + n += 1; + } + if (!b || i + 1 == on.len()) && n > 0 { + runs.push(n); + n = 0; + } + } + // The first and last run may be cut by the scan's ends. + let inner = runs.get(1..runs.len().saturating_sub(1)).unwrap_or(&[]); + assert!(inner.iter().all(|&n| n == 4 * w), "@{ppp}x {name} row {row}: dashes {runs:?}, want {} px", 4 * w); + } + } + } +} + +/// On black the white dashes show, between black ones. +#[test] +fn the_ants_alternate_black_and_white() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some(mut h) = harness(1.0) else { return }; + h.run_steps(4); + let r = [100, 60, 200, 240]; + setup(&mut h, "black", r); + let img = render(&mut h); + let (a, b) = (device(&h, 1.0, 100.0, 60.0), device(&h, 1.0, 200.0, 60.0)); + let mut bright = 0; + let mut n = 0; + for x in a.0 + 12..b.0 - 12 { + n += 1; + bright += usize::from((-2..=2).any(|o| img.luma(x, a.1 + o).is_some_and(|l| l > 230))); + } + let frac = bright as f32 / n as f32; + assert!((0.4..=0.6).contains(&frac), "{frac:.2} of the top edge is white on black"); +} diff --git a/crates/ui-egui/tests/marquee_autoscroll.rs b/crates/ui-egui/tests/it/marquee_autoscroll.rs similarity index 98% rename from crates/ui-egui/tests/marquee_autoscroll.rs rename to crates/ui-egui/tests/it/marquee_autoscroll.rs index 2fc5772604..ab5b6f6bb5 100644 --- a/crates/ui-egui/tests/marquee_autoscroll.rs +++ b/crates/ui-egui/tests/it/marquee_autoscroll.rs @@ -82,6 +82,7 @@ fn selection(h: &Harness) -> Option<[i32; 4]> { #[test] fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; setup(&mut h, "rectMarquee"); let start = xf(&h); @@ -121,6 +122,7 @@ fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { #[test] fn a_drag_inside_the_canvas_does_not_scroll() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; setup(&mut h, "rectMarquee"); let start = xf(&h); @@ -139,6 +141,7 @@ fn a_drag_inside_the_canvas_does_not_scroll() { #[test] fn rotated_marquee_scrolls_on_the_axis_that_is_visibly_clipped() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness_with_size(egui::vec2(1200.0, 640.0)) else { return }; setup(&mut h, "rectMarquee"); let rect = xf(&h).rect; diff --git a/crates/ui-egui/tests/paint_bucket_all_layers.rs b/crates/ui-egui/tests/it/paint_bucket_all_layers.rs similarity index 100% rename from crates/ui-egui/tests/paint_bucket_all_layers.rs rename to crates/ui-egui/tests/it/paint_bucket_all_layers.rs diff --git a/crates/ui-egui/tests/pattern_preview_canvas.rs b/crates/ui-egui/tests/it/pattern_preview_canvas.rs similarity index 98% rename from crates/ui-egui/tests/pattern_preview_canvas.rs rename to crates/ui-egui/tests/it/pattern_preview_canvas.rs index 627bbddaa2..aef11c00dd 100644 --- a/crates/ui-egui/tests/pattern_preview_canvas.rs +++ b/crates/ui-egui/tests/it/pattern_preview_canvas.rs @@ -52,6 +52,7 @@ fn near(a: [u8; 3], b: [u8; 3]) -> bool { #[test] fn pattern_preview_repeats_the_document_on_gpu_and_cpu_canvases() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; let app = h.state_mut(); app.run("file.new", json!({"width": 200, "height": 100, "mode": "rgb", "depth": 8})).expect("new"); diff --git a/crates/ui-egui/tests/it/radial_blur_center.rs b/crates/ui-egui/tests/it/radial_blur_center.rs new file mode 100644 index 0000000000..8fc14ab7c7 --- /dev/null +++ b/crates/ui-egui/tests/it/radial_blur_center.rs @@ -0,0 +1,237 @@ +//! Radial Blur's Zoom diagram shares the dialog's centre coordinates with its sliders, +//! numeric fields, automation, preview and final command. Uses the real dialog through +//! the public app/control APIs, like custom_print_paper.rs; no GPU adapter is needed. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape, Vec2, accesskit::Role, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_ui_egui::{ + PhotocraftApp, + control::{ControlRequest, Outcome, handle}, + dialogs, filter_dialog, i18n, + theme::ThemeKind, +}; +use serde_json::{Map, Value, json}; + +const COMMAND: &str = "filter.blur.radialBlur"; + +fn harness() -> Harness<'static, PhotocraftApp> { + themed_harness(ThemeKind::Pro, vec2(1000.0, 800.0)) +} + +fn themed_harness(theme: ThemeKind, size: Vec2) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("paint.stroke", json!({"points": [[8, 8, 1]], "size": 4, "color": "#ff8000"})).unwrap(); + let mut h = Harness::builder().with_size(size).build_ui_state( + |ui, app| { + let _language = i18n::language_scope(i18n::Lang::EN); + dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + control(&mut h, "ui.menu.invoke", json!({"id": COMMAND})); + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.set", json!({"dialog": id, "field": "method", "value": "zoom"})); + h +} + +fn control(h: &mut Harness<'_, PhotocraftApp>, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (request, _rx) = ControlRequest::new(method, params); + let result = handle(h.state_mut(), &ctx, &request); + assert!(matches!(result, Outcome::Done(v) if v["ok"] == true), "{method} succeeds"); + h.run_steps(3); +} + +fn fields<'a>(h: &'a Harness<'_, PhotocraftApp>) -> &'a Map { + &h.state().ui.dialogs.last().unwrap().fields +} + +fn center(h: &Harness<'_, PhotocraftApp>) -> [f64; 2] { + [fields(h)["centerX"].as_f64().unwrap(), fields(h)["centerY"].as_f64().unwrap()] +} + +fn diagram(h: &Harness<'_, PhotocraftApp>) -> Rect { + h.get_by_role_and_label(Role::Slider, "Center").rect() +} + +fn pointer(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2, pressed: bool) { + h.event(Event::PointerMoved(pos)); + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, x: f32, y: f32) { + let plot = diagram(h).shrink(4.0); + let pos = plot.min + vec2(x, y) * plot.size(); + pointer(h, pos, true); + pointer(h, pos, false); + h.run_steps(2); +} + +fn marker(shape: &Shape, rect: Rect) -> Option { + match shape { + Shape::Circle(c) if c.radius == 3.0 && rect.contains(c.center) => Some(c.center), + Shape::Vec(shapes) => shapes.iter().find_map(|s| marker(s, rect)), + _ => None, + } +} + +fn assert_marker(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) { + let rect = diagram(h); + let plot = rect.shrink(4.0); + let expected = plot.min + vec2(x, y) * plot.size(); + let actual = h.output().shapes.iter().find_map(|s| marker(&s.shape, rect)).expect("painted centre marker"); + assert!(actual.distance(expected) < 0.1, "marker at {actual:?}, expected {expected:?}"); +} + +#[test] +fn clicks_and_drags_update_both_coordinates_and_clamp_at_the_edges() { + let mut h = harness(); + click(&mut h, 0.25, 0.75); + assert_eq!(center(&h), [0.25, 0.75]); + assert_marker(&h, 0.25, 0.75); + let plot = diagram(&h).shrink(4.0); + pointer(&mut h, plot.min + plot.size() * 0.5, true); + assert_eq!(center(&h), [0.5, 0.5], "the press itself moves the centre"); + for (point, expected) in [(vec2(0.8, 0.2), [0.8, 0.2]), (vec2(1.4, -0.4), [1.0, 0.0]), (vec2(-0.4, 1.4), [0.0, 1.0])] { + h.event(Event::PointerMoved(plot.min + plot.size() * point)); + h.step(); + assert_eq!(center(&h), expected, "updates during the drag, before release"); + assert_marker(&h, expected[0] as f32, expected[1] as f32); + } + pointer(&mut h, plot.left_bottom(), false); + h.run_steps(2); + assert_eq!(center(&h), [0.0, 1.0]); +} + +#[test] +fn slider_and_numeric_edits_move_the_diagram() { + let mut h = harness(); + let slider = h.get_by_role_and_label(Role::Slider, "Center X").rect(); + let at = |fraction| pos2(slider.left() + 7.0 + (slider.width() - 14.0) * fraction, slider.center().y); + pointer(&mut h, at(0.15), true); + h.event(Event::PointerMoved(at(0.8))); + h.step(); + pointer(&mut h, at(0.8), false); + h.run_steps(2); + assert_eq!(center(&h), [0.8, 0.5]); + assert_marker(&h, 0.8, 0.5); + h.get_all_by_role(Role::SpinButton).nth(2).expect("Center Y numeric field").click(); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.event(Event::Text("0.23".into())); + h.key_press(Key::Tab); + h.run_steps(3); + assert_eq!(center(&h), [0.8, 0.23]); + assert_marker(&h, 0.8, 0.23); +} + +#[test] +fn keyboard_steps_and_automation_share_the_diagram_coordinates() { + let mut h = harness(); + click(&mut h, 1.0, 0.0); + h.key_press(Key::ArrowRight); + h.key_press(Key::ArrowUp); + h.run_steps(2); + assert_eq!(center(&h), [1.0, 0.0]); + h.key_press(Key::ArrowLeft); + h.key_press(Key::ArrowDown); + h.run_steps(2); + assert_eq!(center(&h), [0.99, 0.01]); + let id = h.state().ui.dialogs.last().unwrap().id; + for (field, value) in [("centerX", 0.12), ("centerY", 0.87)] { + control(&mut h, "ui.dialog.set", json!({"dialog": id, "field": field, "value": value})); + } + h.run_steps(3); + assert_marker(&h, 0.12, 0.87); +} + +#[test] +fn only_zoom_has_the_diagram_and_switching_methods_keeps_the_center() { + let mut h = harness(); + click(&mut h, 0.2, 0.7); + for method in ["spin", "zoom"] { + h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("method".into(), json!(method)); + h.run_steps(3); + assert_eq!(h.query_by_role_and_label(Role::Slider, "Center").is_some(), method == "zoom"); + let [x, y] = center(&h); + assert!((x - 0.2).abs() < 1e-6 && (y - 0.7).abs() < 1e-6); + assert_eq!(h.get_all_by_role(Role::SpinButton).count(), 3, "no duplicated centre controls"); + } + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.cancel", json!({"dialog": id})); + control(&mut h, "ui.menu.invoke", json!({"id": "filter.blur.gaussianBlur"})); + assert!(h.query_by_role_and_label(Role::Slider, "Center").is_none(), "other filters keep their existing controls"); +} + +#[test] +fn editing_the_diagram_is_only_a_preview_until_confirmed() { + let mut h = harness(); + let revision = h.state().session.active().unwrap().revision; + click(&mut h, 0.25, 0.75); + assert_eq!(h.state().session.active().unwrap().revision, revision); + let before = h.state().session.active().unwrap().doc.clone(); + let params = filter_dialog::params_of(fields(&h)); + let active_layer = h.state().session.active().unwrap().active_layer; + let expected = filter_dialog::preview_document(&before, active_layer, COMMAND, ¶ms, 1).unwrap(); + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.confirm", json!({"dialog": id})); + let actual = &h.state().session.active().unwrap().doc; + assert_eq!(photocraft_compose::render(actual, actual.bounds()), photocraft_compose::render(&expected, expected.bounds())); + assert!(h.state().session.active().unwrap().revision > revision); + control(&mut h, "ui.menu.invoke", json!({"id": COMMAND})); + let id = h.state().ui.dialogs.last().unwrap().id; + let revision = h.state().session.active().unwrap().revision; + click(&mut h, 0.8, 0.2); + control(&mut h, "ui.dialog.cancel", json!({"dialog": id})); + assert_eq!(h.state().session.active().unwrap().revision, revision); +} + +#[test] +fn hostile_coordinates_are_bounded_before_drawing() { + let mut h = harness(); + for (x, y, expected) in [(json!(1e100), json!(-1e100), [1.0, 0.0]), (json!("invalid"), Value::Null, [0.5, 0.5])] { + let fields = &mut h.state_mut().ui.dialogs.last_mut().unwrap().fields; + fields.insert("centerX".into(), x); + fields.insert("centerY".into(), y); + h.run_steps(3); + assert_eq!(center(&h), expected); + assert_marker(&h, expected[0] as f32, expected[1] as f32); + } +} + +#[test] +fn diagram_and_sliders_fit_every_theme_and_amount_changes_the_rays() { + for theme in ThemeKind::ALL { + for width in [560.0, 1000.0] { + let mut h = themed_harness(theme, vec2(width, 800.0)); + click(&mut h, 0.25, 0.75); + let diagram = h.get_by_role_and_label(Role::Slider, "Center").rect(); + let viewport = Rect::from_min_size(Pos2::ZERO, vec2(width, 800.0)); + assert!(viewport.contains_rect(diagram), "{theme:?}: the diagram fits at {width} pt"); + for label in ["Center X", "Center Y"] { + let slider = h.get_by_role_and_label(Role::Slider, label).rect(); + assert!(slider.left() > diagram.right(), "{theme:?}: {label} is beside the diagram"); + assert!(viewport.contains_rect(slider), "{theme:?}: {label} fits at {width} pt"); + } + let rays = |h: &Harness<'_, PhotocraftApp>| { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::LineSegment { points, .. } if points.iter().all(|p| diagram.contains(*p)) => Some(*points), + _ => None, + }) + .collect::>() + }; + let before = rays(&h); + assert!(!before.is_empty(), "the diagram paints the streaks"); + let id = h.state().ui.dialogs.last().unwrap().id; + control(&mut h, "ui.dialog.set", json!({"dialog": id, "field": "amount", "value": 75})); + assert_ne!(before, rays(&h), "Amount changes the schematic streaks"); + assert_eq!(center(&h), [0.25, 0.75]); + } + } +} diff --git a/crates/ui-egui/tests/remove_channel_target.rs b/crates/ui-egui/tests/it/remove_channel_target.rs similarity index 100% rename from crates/ui-egui/tests/remove_channel_target.rs rename to crates/ui-egui/tests/it/remove_channel_target.rs diff --git a/crates/ui-egui/tests/system_locale.rs b/crates/ui-egui/tests/it/system_locale.rs similarity index 100% rename from crates/ui-egui/tests/system_locale.rs rename to crates/ui-egui/tests/it/system_locale.rs diff --git a/crates/ui-egui/tests/ui_scale_canvas.rs b/crates/ui-egui/tests/it/ui_scale_canvas.rs similarity index 100% rename from crates/ui-egui/tests/ui_scale_canvas.rs rename to crates/ui-egui/tests/it/ui_scale_canvas.rs diff --git a/crates/ui-egui/tests/variables_data_sets.rs b/crates/ui-egui/tests/it/variables_data_sets.rs similarity index 100% rename from crates/ui-egui/tests/variables_data_sets.rs rename to crates/ui-egui/tests/it/variables_data_sets.rs diff --git a/crates/ui-egui/tests/workspace_presets.rs b/crates/ui-egui/tests/it/workspace_presets.rs similarity index 100% rename from crates/ui-egui/tests/workspace_presets.rs rename to crates/ui-egui/tests/it/workspace_presets.rs diff --git a/crates/ui-egui/tests/support/layout_doc.rs b/crates/ui-egui/tests/support/layout_doc.rs index f44fac16a6..f79523d505 100644 --- a/crates/ui-egui/tests/support/layout_doc.rs +++ b/crates/ui-egui/tests/support/layout_doc.rs @@ -88,6 +88,7 @@ fn stroke_fx(size: f32, c: [f32; 3]) -> Effect { size, position: StrokePosition::Outside, paint: FxPaint::Color(Color::rgb(c[0], c[1], c[2])), + overprint: false, }) } diff --git a/crates/vector/src/feather.rs b/crates/vector/src/feather.rs new file mode 100644 index 0000000000..5048916d49 --- /dev/null +++ b/crates/vector/src/feather.rs @@ -0,0 +1,281 @@ +//! Mask feathering: the Gaussian blur Properties › Feather applies to a mask (pixel masks, vector +//! masks and a shape layer's own path, so equal feathers match exactly). Pure `f32` planes; the +//! compositor (`photocraft-compose::masks`) and the shape renderer share it. + +use photocraft_doc::Path; +use photocraft_geom::Rect; + +/// 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; + +/// `r` grown by a feather of Gaussian `sigma`: how far the blur carries a mask's edge (three +/// sigmas and two pixels, beyond which it is constant to well under a level). +pub fn grow(r: Rect, sigma: f32) -> Rect { + 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 a feathered path mask can differ from its value far outside the path: the path's control +/// bounds (and two pixels of anti-aliasing) grown by the feather. Empty for a path without knots. +/// A vector mask and a soft shape layer use the same area, so equal settings match exactly. +pub fn path_area(path: &Path, sigma: f32) -> Rect { + match 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), + ), + sigma, + ), + None => Rect::EMPTY, + } +} + +/// 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. +pub 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]; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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:?}"); + } +} diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index d336435492..ac5fe8aad5 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -5,6 +5,7 @@ //! mask back into a path for "Make Work Path"). //! //! * [`edit`]: point-level path editing (Direct Selection, Convert Point) and hit testing. +//! * [`feather`]: the Gaussian blur of a feathered mask (vector masks, pixel masks, soft shapes). //! * [`flatten`]: cubic Bézier flattening with a distance tolerance. //! * [`map`]: non-affine maps of paths (Distort, Perspective, Warp). //! * [`raster`]: exact area-coverage scanline rasterizer (non-zero / even-odd per component, @@ -20,6 +21,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod edit; +pub mod feather; pub mod flatten; pub mod map; pub mod raster; @@ -458,9 +460,101 @@ fn dither(c: &mut [f32; 4], x: i32, y: i32) { } } +/// How much of a shape's fill shows at each pixel: the path's coverage, or (with a density or +/// feather) the path as the fill's soft vector mask. +enum FillCoverage { + Path(Rasterizer), + /// Density without feather: `1 - density × (1 - coverage)`. + Dense(Rasterizer, f32), + /// Feathered (and maybe less dense): the mask over `area`, `outside` everywhere else. + Plane { + area: Rect, + values: Vec, + outside: f32, + }, +} + +impl FillCoverage { + /// Builds the fill's coverage for `shape` over the frame `canvas` (a feather is blurred + /// within it, as the compositor blurs a vector mask within the canvas). + fn new(shape: &ShapeLayer, area: &Rasterizer, canvas: Rect) -> Self { + if !shape.has_soft_mask() { + return FillCoverage::Path(area.clone()); + } + let density = if shape.density.is_finite() { shape.density.clamp(0.0, 1.0) } else { 1.0 }; + let sigma = feather::feather_sigma(shape.feather); + if sigma <= 0.0 { + return FillCoverage::Dense(area.clone(), density); + } + // Exactly what the compositor does for a vector mask with these settings + // (`photocraft-compose::masks::build_mask`): coverage with density, blurred. + let soften = |v: &mut [f32]| { + if density < 1.0 { + for x in v.iter_mut() { + *x = 1.0 - density * (1.0 - *x); + } + } + }; + let mut far = area.render(Rect::from_xywh(canvas.x0.saturating_sub(1_000_000), canvas.y0.saturating_sub(1_000_000), 1, 1)); + soften(&mut far); + let outside = far.first().copied().unwrap_or(0.0); + let region = feather::path_area(&shape.path, sigma).intersect(&canvas); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut values = Vec::new(); + if values.try_reserve_exact(w.saturating_mul(h)).is_err() { + // Too large to blur: keep the edge hard rather than fail the render. + return FillCoverage::Dense(area.clone(), density); + } + values.resize(w * h, 0.0); + if !values.is_empty() { + area.render_into(region, &mut values); + soften(&mut values); + feather::gaussian(&mut values, w, h, sigma); + } + FillCoverage::Plane { area: region, values, outside } + } + + /// Pixel bounds of the visible fill (`None` = nothing). + fn bounds(&self) -> Option { + const ALL: Rect = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4); + match self { + FillCoverage::Path(r) => r.pixel_bounds(), + FillCoverage::Dense(_, d) if *d < 1.0 => Some(ALL), + FillCoverage::Dense(r, _) => r.pixel_bounds(), + FillCoverage::Plane { outside, .. } if *outside > 0.0 => Some(ALL), + FillCoverage::Plane { area, .. } => (!area.is_empty()).then_some(*area), + } + } + + fn render(&self, rect: Rect) -> Vec { + match self { + FillCoverage::Path(r) => r.render(rect), + FillCoverage::Dense(r, d) => { + let mut v = r.render(rect); + for x in &mut v { + *x = 1.0 - d * (1.0 - *x); + } + v + } + FillCoverage::Plane { area, values, outside } => { + let w = area.width() as usize; + let mut v = Vec::with_capacity(rect.width() as usize * rect.height() as usize); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let inside = area.contains(x, y); + let i = if inside { (y - area.y0) as usize * w + (x - area.x0) as usize } else { usize::MAX }; + v.push(values.get(i).copied().unwrap_or(*outside)); + } + } + v + } + } + } +} + /// Everything needed to render a shape layer, compiled once. pub struct CompiledShape { - fill: Option<(Rasterizer, Paint)>, + fill: Option<(FillCoverage, Paint)>, stroke: Option<(Rasterizer, Paint, f32)>, /// Fill area used to clip inside/outside strokes. align: Option<(Rasterizer, bool)>, @@ -476,8 +570,9 @@ impl CompiledShape { let area = fill_rasterizer(&shape.path, tol); let mut bounds = None; let fill = shape.fill.as_ref().map(|f| { - bounds = area.pixel_bounds(); - (area.clone(), Paint::from_fill(f, pb, cb)) + let coverage = FillCoverage::new(shape, &area, canvas); + bounds = coverage.bounds(); + (coverage, Paint::from_fill(f, pb, cb)) }); let all_closed = shape.path.subpaths.iter().all(|s| s.closed); let mut align = None; diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index b154a47325..dfeae26e81 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -111,6 +111,101 @@ pub fn polygon(x: f64, y: f64, w: f64, h: f64, sides: u32, star_ratio: f64) -> P Path::new(vec![Subpath::polygon(&pts)]) } +/// A rounded vertex: see [`fillet`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Fillet { + /// The arc's centre and its tangent points on the edge to the previous and the next vertex. + pub centre: [f64; 2], + pub from: [f64; 2], + pub to: [f64; 2], + /// The radius used (the one asked for, capped at what the edges allow). + pub radius: f64, + /// Unit direction from the vertex towards the arc's centre, and half the vertex's angle. + pub bisector: [f64; 2], + pub half_angle: f64, +} + +/// Where a vertex's rounding sits: the vertex `v` between neighbours `prev` and `next`, rounded +/// with radius `r`, capped so its tangent points stay within half of each edge (two neighbouring +/// roundings never overlap). `None` for `r <= 0` or a degenerate vertex (a zero-length edge, or +/// a straight or folded angle). +pub fn fillet(prev: [f64; 2], v: [f64; 2], next: [f64; 2], r: f64) -> Option { + let unit = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy); + (l > 1e-9).then(|| ([dx / l, dy / l], l)) + }; + if r.is_nan() || r <= 0.0 { + return None; + } + let (u1, l1) = unit(v, prev)?; + let (u2, l2) = unit(v, next)?; + let cos = (u1[0] * u2[0] + u1[1] * u2[1]).clamp(-1.0, 1.0); + let half = cos.acos() / 2.0; + if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-6 { + return None; + } + let r = r.min(l1.min(l2) / 2.0 * half.tan()); + let t = r / half.tan(); + let (bx, by) = (u1[0] + u2[0], u1[1] + u2[1]); + let bl = bx.hypot(by); + if bl < 1e-9 { + return None; + } + let bisector = [bx / bl, by / bl]; + let c = r / half.sin(); + Some(Fillet { + centre: [v[0] + bisector[0] * c, v[1] + bisector[1] * c], + from: [v[0] + u1[0] * t, v[1] + u1[1] * t], + to: [v[0] + u2[0] * t, v[1] + u2[1] * t], + radius: r, + bisector, + half_angle: half, + }) +} + +/// `path` (closed subpaths of corner knots, as [`polygon`] makes) with every vertex rounded by a +/// circular arc of radius `r` tangent to both its edges, each capped as [`fillet`] does, like +/// Photoshop's rounded triangles and polygons. `r <= 0` leaves it sharp. +pub fn round_corners(path: Path, r: f64) -> Path { + if r.is_nan() || r <= 0.0 { + return path; + } + let subpaths = path + .subpaths + .into_iter() + .map(|sp| { + let n = sp.knots.len(); + if n < 3 || !sp.closed { + return sp; + } + let pt = |i: usize| sp.knots.get(i % n).map_or([0.0; 2], |k| [k.anchor.x, k.anchor.y]); + let mut knots = Vec::with_capacity(n * 2); + for i in 0..n { + let (prev, v, next) = (pt(i + n - 1), pt(i), pt(i + 1)); + let Some(f) = fillet(prev, v, next, r) else { + knots.push(Knot::corner(v[0], v[1])); + continue; + }; + // The arc as one cubic: handles 4/3 tan(sweep/4) r long, along the tangents + // towards the vertex; the sweep is pi minus the vertex's angle. + let sweep = std::f64::consts::PI - 2.0 * f.half_angle; + let h = 4.0 / 3.0 * (sweep / 4.0).tan() * f.radius; + let toward = |a: [f64; 2]| { + let (dx, dy) = (v[0] - a[0], v[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + Point::new(a[0] + dx / l * h, a[1] + dy / l * h) + }; + let (from, to) = (Point::new(f.from[0], f.from[1]), Point::new(f.to[0], f.to[1])); + knots.push(Knot { anchor: from, in_ctrl: from, out_ctrl: toward(f.from), smooth: false }); + knots.push(Knot { anchor: to, in_ctrl: toward(f.to), out_ctrl: to, smooth: false }); + } + Subpath { knots, ..sp } + }) + .collect(); + Path { subpaths, ..path } +} + /// A line drawn as a filled bar of `weight` px from `a` to `b`. pub fn line(a: (f64, f64), b: (f64, f64), weight: f64) -> Path { let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -125,7 +220,9 @@ pub fn live_path(s: &LiveShape) -> Path { match s { LiveShape::Rect { rect: [x, y, w, h], radii } => rounded_rect(*x, *y, *w, *h, *radii), LiveShape::Ellipse { rect: [x, y, w, h] } => ellipse(*x, *y, *w, *h), - LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio } => polygon(*x, *y, *w, *h, *sides, *star_ratio), + LiveShape::Polygon { rect: [x, y, w, h], sides, star_ratio, corner_radius } => { + round_corners(polygon(*x, *y, *w, *h, *sides, *star_ratio), *corner_radius) + } LiveShape::Line { from, to, weight } => line((from[0], from[1]), (to[0], to[1]), *weight), } } @@ -134,6 +231,59 @@ pub fn live_path(s: &LiveShape) -> Path { mod tests { use super::*; + /// The point at `t` of the cubic leaving `a` towards `b`. + fn at(a: &Knot, b: &Knot, t: f64) -> [f64; 2] { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + let p = [a.anchor, a.out_ctrl, b.in_ctrl, b.anchor]; + [p.iter().zip(w).map(|(p, w)| p.x * w).sum(), p.iter().zip(w).map(|(p, w)| p.y * w).sum()] + } + + #[test] + fn a_rounded_triangle_has_one_arc_per_vertex_on_a_true_circle() { + let sharp = polygon(0.0, 0.0, 300.0, 300.0, 3, 1.0); + assert_eq!(sharp.subpaths[0].knots.len(), 3); + let p = round_corners(sharp.clone(), 20.0); + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 6, "two knots per rounded vertex"); + // Each arc stays 20 px from its centre along its length (one cubic, within 0.1 px). + let verts: Vec<[f64; 2]> = sharp.subpaths[0].knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + for i in 0..3 { + let f = fillet(verts[(i + 2) % 3], verts[i], verts[(i + 1) % 3], 20.0).unwrap(); + assert!((f.radius - 20.0).abs() < 1e-9); + assert_eq!([k[2 * i].anchor.x, k[2 * i].anchor.y], f.from); + for t in [0.25, 0.5, 0.75] { + let q = at(&k[2 * i], &k[2 * i + 1], t); + let d = (q[0] - f.centre[0]).hypot(q[1] - f.centre[1]); + assert!((d - 20.0).abs() < 0.1, "vertex {i} t {t}: {d}"); + } + } + // The edges between the arcs stay straight. + assert_eq!((k[1].out_ctrl, k[2].in_ctrl), (k[1].anchor, k[2].anchor)); + } + + #[test] + fn huge_radii_are_capped_and_zero_stays_sharp() { + let sharp = polygon(0.0, 0.0, 100.0, 100.0, 6, 1.0); + assert_eq!(round_corners(sharp.clone(), 0.0), sharp); + assert_eq!(round_corners(sharp.clone(), f64::NAN), sharp); + let p = round_corners(sharp, 1.0e9); + // Capped: the tangent points of neighbouring vertices meet halfway along each edge. + let k = &p.subpaths[0].knots; + assert_eq!(k.len(), 12); + for w in k.windows(2).skip(1).step_by(2) { + let d = (w[0].anchor.x - w[1].anchor.x).hypot(w[0].anchor.y - w[1].anchor.y); + assert!(d < 1e-6, "no straight left between arcs: {d}"); + } + assert!(k.iter().all(|k| k.anchor.x.is_finite() && k.anchor.y.is_finite())); + } + + #[test] + fn a_star_rounds_its_inner_vertices_too() { + let p = live_path(&LiveShape::Polygon { rect: [0.0, 0.0, 200.0, 200.0], sides: 5, star_ratio: 0.5, corner_radius: 6.0 }); + assert_eq!(p.subpaths[0].knots.len(), 20); + } + #[test] fn rounded_rect_knots() { let p = rounded_rect(0.0, 0.0, 100.0, 50.0, [10.0; 4]); diff --git a/docs/architecture.md b/docs/architecture.md index 79c674bf23..f4b80270b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -555,7 +555,7 @@ codegen-units = 1 opt-level = 2 # image code is unusable at opt-level 0 ``` -Versions get pinned when we scaffold, using the numbers in `rust-framework-options.md`. +Dependency requirements are centralized in the workspace `Cargo.toml` (`[workspace.dependencies]`); `Cargo.lock` records the exact resolved versions. --- @@ -578,17 +578,16 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 ## 16. Open decisions -1. **UI toolkit.** The leading option is egui on winit + wgpu. Bevy, Slint and Qt are the alternatives (see `rust-framework-options.md`). This plan works with any of them. -2. **License.** MIT/Apache-2.0 is maximally reusable. GPL-3.0 is the Krita/GIMP model and protects against proprietary forks. Note that some candidate deps are LGPL (rawler) or GPL-or-commercial (Slint), and MIT/Apache would constrain which of them we can use. -3. **Project name.** `photocraft` (the repo name) is assumed. -4. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. -5. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. -6. **Scope of Photoshop plugin compatibility.** Decided: no native `.8BF` hosting; a sandboxed WebAssembly plug-in API instead (see [`plugins.md`](plugins.md)). +1. **License.** MIT/Apache-2.0 is maximally reusable. GPL-3.0 is the Krita/GIMP model and protects against proprietary forks. Note that some candidate deps are LGPL (rawler) or GPL-or-commercial (Slint), and MIT/Apache would constrain which of them we can use. +2. **Project name.** `photocraft` (the repo name) is assumed. +3. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. +4. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Removed references to the deleted `rust-framework-options.md`; dropped the UI toolkit and plug-in items from Open decisions, both recorded as decided | | 2026-10-20 | minor | GPU display matrix/shaper path preserves near-black ICC values; complex transforms retain the 3D LUT fallback (#7958) | | 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged | | 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 5c75a3e55f..16eeb07198 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -26,11 +26,13 @@ tablets against Photoshop side by side. | Color Dynamics (FG/BG, hue/sat/brightness jitter, purity, per tip) | done | | | Hue Jitter uses a standard percentage-amount track instead of a hue spectrum | done | #3825 (Hue Jitter appearance only); rendered track checked in all 14 themes; numeric and pointer edits retain percentage units and engine command routing. | | Transfer (opacity/flow/wetness/mix jitter and controls) | done | | +| Transfer Wetness/Mix blocks disabled outside Mixer Brush, retaining their settings | done | #3825 (tool applicability only); UI regressions cover all 14 themes and 67 tools, disabled input, section/Control Off, command routing and per-tool settings | | Shape Dynamics and Transfer minimum fields disabled when their Control is Off, retaining their values | done | #3825 (minimum-field interaction only); `brush_sections.rs` UI regressions cover all six fields, reactivation and disabled input | | Brush Pose (tilt, rotation, pressure overrides) | done | | +| Brush Pose: separate Override Tilt X/Y flags, preserved through ABR import, presets and commands | done | #3825 (axis overrides only); imported single-axis flags retain the other live stylus component; dab/pixel tests cover 8/16/32-bit RGBA; legacy combined inputs migrate and command deltas contain only the edited axis. | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | -| Section locks | done | #174 | +| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus visible themed focus, Tab blur, Space activation, minimal command patches and replay without selecting or enabling a section. | | Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | @@ -61,7 +63,7 @@ tablets against Photoshop side by side. | Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | | No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | | Live stroke for Brush and Eraser | done | | -| Live stroke for every brush-based retouch tool | missing | TOOL-213-7 | +| Live stroke for every brush-based retouch tool | partial | TOOL-213-7: all live but the Mixer Brush (PR #3627); Spot Healing shows the area it heals and heals on release, as Photoshop does | | No OS arrow cursor over the canvas with a pen | partial | TOOL-213-14 | ## Gestures and cursors @@ -69,7 +71,7 @@ tablets against Photoshop side by side. | Behaviour | Status | Evidence | |---|---|---| | ⌃⌥-drag (macOS) / Alt+right-drag (Windows) resizes and changes hardness with a readout | done | TOOL-213-10 | -| `[` `]` size, `⇧[` `⇧]` hardness, number keys opacity, ⇧+numbers flow | done | 2026-10-07 | +| `[` `]` size, `⇧[` `⇧]` hardness, number keys opacity, ⇧+numbers flow (swapped when build-up is on) | done | 2026-10-07 | | ⌥-click samples a colour with painting tools; Eyedropper comparison ring | done | TOOL-213-9 | | ⇧-click draws a straight line from the last point | done | | | Hold `~` with the Brush or Pencil to erase with its brush: Clear mode while held (rebindable under Tools › Temporary) | done | `hold_keys.rs`, #3439 | @@ -116,11 +118,15 @@ tablets against Photoshop side by side. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Brush Pose tilt overrides are independent end to end (#3825 partial); ABR flags, legacy JSON/commands, presets and three pixel depths covered; no readiness percentage or estimate changed | | 2026-10-13 | minor | Stale "missing tool" row corrected against main: Color Replacement (`paint.colorReplacement`) is a live toolbar tool; no brush-feel estimate changed | +| 2026-10-11 | minor | Pattern Stamp and Background Eraser draw live (#3754); live retouch row now partial (Mixer Brush left) | | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | +| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | | 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | diff --git a/docs/context-menu-inventory.md b/docs/context-menu-inventory.md index 3d7bc522fb..a932a58b20 100644 --- a/docs/context-menu-inventory.md +++ b/docs/context-menu-inventory.md @@ -30,7 +30,7 @@ Canvas invariant: secondary click must not accidentally start a paint stroke, co | Surface | Current entries or behavior | Command route / gap | |---|---|---| | Layers panel row | Blending Options; Duplicate/Delete; Group from Layers; Quick Export PNG / Export As; Artboard/Frame from Layers; Convert to Smart Object; content-specific Rasterize; Add/Disable/Apply/Delete Layer Mask; Create/Release Clipping Mask; Link/Select Linked; Copy/Paste/Clear Layer Style; Merge Layers/Down, Merge Visible, Flatten Image; Rename Layer. Multi-select changes duplicate/delete/merge labels and target. | `layer_menu_ui::entries` supplies IDs for all except inline rename, then `menus::invoke` or engine dispatch. Missing or disabled commands are filtered/greyed. Check menu visibility and enabled state for groups, backgrounds, masks, type, adjustment/fill, smart objects, and multi-selection. | -| Channels panel row | Alpha: Duplicate/Delete/Rename, spot conversion or merge, selection-display and overlay controls. Quick Mask: Exit, display and overlay. Composite/color: Duplicate. Layer mask: Enable/Disable, Delete. Common: New Channel, New Spot Channel, Split, Merge. | `channel.*`, `layer.layerMask.*`, `select.editInQuickMaskMode`, `ui.renameChannel`. Check selection loading/saving, channel options, and per-row enablement against Photoshop. | +| Channels panel row | Alpha: Duplicate/Delete/Rename, spot conversion or merge, selection-display and overlay controls. Quick Mask: Exit, display and overlay. Composite/color: Duplicate. Layer mask: Enable/Disable, Delete, Layer Mask Display Options… (also on double-click). Common: New Channel, New Spot Channel, Split, Merge. | `channel.*`, `layer.layerMask.*`, `select.editInQuickMaskMode`, `ui.renameChannel`. Check selection loading/saving, channel options, and per-row enablement against Photoshop. | | Brushes panel preset | Rename Brush, Delete Brush. | Routed through `brush.presets.*` panel actions. Check duplicate/export/import and library organization. | | Brushes panel group | Rename Group, Delete Group. | Routed through preset panel actions; confirm group deletion consequences. | | Generic presets group (gradients, patterns, swatches, etc.) | Rename Group, Delete Group. | `preset_panels.rs`; verify each collection's appropriate create, export/import, and delete semantics. | diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 53b6d99bd9..6663cf0b00 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-20 · **Change:** minor (Zoom tool menu order, #6005; Type tool menu over committed type, #7324; earlier: Path Selection canvas menu, #1056; pasteboard row: the menu exists, #2462) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -28,7 +28,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | | Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370). Over committed type (no edit running, `TYPE_LAYER_MENU`, #7324): the same rows without Undo, Cut, Copy, Paste, Select All and Faux Bold/Italic; the clicked type becomes the active layer first (a layer multiselection stays). The Type Mask tools get no menu. Ignore / Ignore All remain open | P1; editing and nonediting states are separate; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | -| Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | +| Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242, #6005): Fit on Screen, Fill Screen, 100%, 200%, Print Size, then Zoom In and Zoom Out after a separator. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. The Path Selection and Direct Selection tools open the same path menu for the selected path (#1056, 2026-10-15); node- and segment-specific rows remain open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | | Slice, Slice Select | Slice properties, duplicate/delete, divide, guides **(inferred context placement)** | No tool menu | P2 | @@ -82,10 +82,10 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho | Brushes and preset panels | Rename/Delete brush or group, export selected brushes, view/reset preset choices | Rename/Delete, and preset group/item menus exist | Adobe explicitly documents Export Selected Brushes from brush/group right-click ([brushes](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html)); Photoshop pop-up-panel rename/delete/reset/view options are [documented](https://helpx.adobe.com/photoshop/using/panels-menus.html), though some are panel-button menus | | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | | Document tab | Close, Close Others, Close All, Reveal in file manager, duplicate/move to window where supported | Close, Close Others, Close All, Reveal in Finder, and drag to reorder implemented; duplicate/move to window where supported remains open | **Inferred** exact list; P1 | -| Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | No menu | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture | +| Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | Implemented (canvas_tool_menu.rs `PASTEBOARD_MENU`, #2462): Default, Black, Dark Gray, Medium Gray, Light Gray, Custom, Select Custom Color… | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture | | Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions live in the Type tool's edit menu (canvas_tool_menu.rs), not in generic fields, which hold no type layer text (UI-217-8) | P1; **inferred** exact spelling list | | Tool icon in Options bar | Reset Tool, Reset All Tools | Audit | Explicitly documented in [Adobe Photoshop reference](https://helpx.adobe.com/archive/en/photoshop/cc/2015/photoshop_reference.pdf); historical source, verify modern label | -| Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Current right-click deletes constraint | P1 behavior mismatch: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | +| Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Right-click a constraint (wide_angle_ui.rs `constraint_menu`): Free, Horizontal, Vertical (current checked), Delete; a right-click away from every constraint opens nothing. Photoshop's Arbitrary angle is not modelled by the solver | Done except Arbitrary: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | ## Delivery slices and acceptance @@ -100,7 +100,9 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-20 | trivial | Zoom tool menu order (#6005) | | 2026-10-18 | minor | Type tool menu over committed type (#7324) | +| 2026-10-15 | trivial | Pasteboard row: the colour menu exists since #2462 | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 918a9f811b..9e60af2136 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,8 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root - `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. -- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. +- Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error. +- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks, as four floats 0..1 (not 0..255). - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. - `ui.set` also accepts `cropFill` (`background`, Photoshop's Background (Default), or `contentAware`), the Crop tool's Fill dropdown (#3823): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. @@ -66,10 +67,11 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`. - The shell's own dialogs (Window › Workspace › New Workspace / Delete Workspace, View › Pixel Aspect Ratio › Custom Pixel Aspect Ratio…, View › Show › Show Extras Options…, View › 32-bit Preview Options…) are listed in `ui.inspect`'s `dialogs` with the id `"shell"` (at most one is open). `ui.dialog.set`, `ui.dialog.confirm` and `ui.dialog.cancel` take `{"dialog": "shell"}`; OK runs the dialog's command and keeps the dialog open if it fails - The unsaved-changes prompt (closing a document, Revert, quitting with unsaved work) is listed in `ui.inspect`'s `dialogs` with the id `"discard"` while it is up: `fields` gives the waiting `command` (`file.close`, `file.exit`…), the `document` it asks about, how many are `remaining`, and its `choices`. `ui.dialog.confirm` takes `{"dialog": "discard", "choice": "save" | "dontSave" | "cancel"}` (default `save`; Revert takes `dontSave` or `cancel`) and answers it as the window's buttons do, one document at a time; `ui.dialog.cancel` with `{"dialog": "discard"}` cancels it. Save of an untitled document opens the save dialog first, so the reply's `fileDialog` is true and the prompt stays open until that is answered. + - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). - - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. + - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. Image Size has `width` and `height` (px), `percent` and `resample` (`nearest|bilinear|bicubic|bicubicSmoother|bicubicSharper`, default `bicubic`); `__constrain` (default true at each opening) links the two sides in proportion. A side edit sets both sides and removes `percent`, which the export command otherwise applies first; a Percent edit sets both sides from it. Each opening starts at the document's full size, while `resample` is remembered with the encoding choices. The document is never resized. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows @@ -83,12 +85,12 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window -- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback +- `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor (after a loss for the rest of the session; after an error until a successful Retry GPU) and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch. Retry GPU saves GPU mode; after an error (`error: true`) it also builds a new GPU canvas on the same device at once (`gpuInfo.canvas` is `gpu` again, #6577), and after a loss it asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback. With `destroy: true` it destroys the window's real wgpu device instead, as a driver reset does (see `docs/development.md` › device loss): the app saves every unsaved document for recovery and closes. Returns `destroyed` - `ui.screenshot {path?, focus?}`: capture the main window (PNG). With no path the reply contains base64 PNG data; a path is relative to the automation write root. Raises the window first (default) because occluded macOS windows stop rendering - `ui.focus`: bring the main window to the front -- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?}`: relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) 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. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) +- `app.open {path}` / `app.save {path?, tgaBits?, ddsCompression?, ddsMipmaps?, quality?, jpegFormat?}` (JPEG: `quality` on the 0–12 scale, `jpegFormat` `baseline`, `optimized` or `progressive`; automation never shows JPEG Options): relative file I/O through the configured automation roots (`app.open` reads under the read root, `app.save` writes under the write root; absolute paths, `..` and paths escaping the root are refused, and both fail closed when no root was granted). Both reply with `warnings` (import/export notes such as "adjustment layer flattened"; `[]` when none), also shown to the user in the status bar and as a notice (`notices` in `ui.inspect`); `app.open` also returns the `path` and document `name` (directory `.pcraft` — the native on-disk form — loads through the read root's capability; the single-file (zip) form takes the bytes path), `app.save` the `path` written. `app.save` without `path` writes back only to the document's own PSD, PSB or `.pcraft` file, like File › Save. `tgaBits` (24 or 32) 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. `ddsCompression` (`none`, `bc1`, `bc2` or `bc3`) and `ddsMipmaps` (bool) set a DDS texture's compression and mip chain; without them, uncompressed without mipmaps. Automation saves never show TGA, DDS, JPEG or TIFF options. Automation opens and saves never fire script events. Use these two rather than `file.open`, `file.save`, `file.saveAs` or `file.saveACopy`, which the control channel refuses (see [Engine commands](#engine-commands)) - `app.quit` ## Engine commands @@ -106,6 +108,7 @@ store expose and persist the same setting; scripts keep using canonical command | `layer.new.layer` | `{"name":"Ink"}` | | `layer.select` | `{"layer":id\|str,"mode":"replace\|toggle\|range\|add\|remove"}` or `{"name":"Background","occurrence":0?}` (occurrence is zero-based document walk order; required for duplicate names): ⌘-click = toggle, ⇧-click = range; `document.inspect` reports `selectedLayers` and a per-layer `selected` flag | | `channel.target` / `channel.setVisible` | Channels panel target and eyes (`"composite"`, colour name, alpha index or name, `"quickMask"`). `document.inspect` (and `channel.list`) report `channels`: composite/colour/alpha rows with visibility, channel options, `quickMask`, `target`. Pixel commands without a `target` param follow the targeted channel | +| `channel.options` | `{"channel":index\|name\|"quickMask"\|"mask",…}`: Channel Options (name, `indicates`, overlay `color`, `opacity`/`solidity`). On `"mask"` it is Layer Mask Display Options: the `color` and `opacity` of every layer mask's rubylith (`\`, ⇧⌥-click), kept in the preferences as `layerMaskOverlay` (not an undo step) and returned under that key | | `layer.setProps` | `{"layer":id?,"name":…,"visible":…,"opacity":0..1,"blend":"Multiply"}` | | `layer.fill.solidColor.set` | `{"layer":id?,"document":id?,"color":"#rrggbb"}`: edit a Solid Color Fill in one undo step; RGB without alpha preserves the fill's alpha. Float RGB arrays and a stored `Color` are also accepted. `document` rejects stale dialog targets. | | `layer.fill.pattern.set` | `{"layer":id?,"document":id?,"pattern":id\|name?,"scale":1..1000?,"angle":-180..180?,"link":bool?,"phase":[x,y]?}`: edit a Pattern Fill (the Pattern Fill dialog's OK) in one undo step; omitted values keep the layer's, `phase` `[0,0]` is Snap to Origin. `layer.newFillLayer.pattern` from a menu (no params) and `layer.layerContentOptions` on a Pattern Fill layer open the Pattern Fill dialog. | @@ -116,7 +119,7 @@ store expose and persist the same setting; scripts keep using canonical command | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | | `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | | `layer.list` | `{"document":index?,"flat":bool?}`: just the layers, top-down: `id`, `name`, `kind`, `visible`, `bounds` (`[x,y,w,h]` or null), `selected`, `active`; groups list their `children`, or with `flat` every layer is one row with its `depth` and `parent` group id | -| `document.pixel` | `{"x":10,"y":10}`: composite RGBA | +| `document.pixel` | `{"x":10,"y":10}`: composite RGBA, four floats 0..1 (not 0..255) | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | | `document.presets.get` | `{"name":"Product square"}` → `{preset,params}`: fetch settings; pass `params` to `file.new` to create a document, optionally adding a document `name` | @@ -385,6 +388,11 @@ no MCP framing, no app start-up per command. Configure its file access with the | `batch` | `{steps: [{command, params?, wait?} \| {method, params?}], stopOnError? (true)}` → `{completed, failed, results}` (a step with `wait: false` starts a long command as a background job, like `engine.execute`) | | `methods` | the list above | +An `index` targets that one call: `doc.place`, `doc.exportLayers`, `doc.exportDataSets`, +`doc.importDataSets`, `doc.save`, `doc.inspect` and `doc.render` act on the document at `index` +without making it the active one, so the next call without `index` still runs on the document +that was active before (`doc.select` is the method that changes it). + ```sh printf '%s\n' \ '{"id":1,"method":"doc.open","params":{"path":"in.jpg"}}' \ @@ -467,7 +475,7 @@ RGB/Grayscale layer. `params: {"smartFilter": {"layer": id, "index": i}}` opens Camera Raw smart filter instead (as double-clicking the filter in the Layers panel does): the stored settings over the pixels below that filter, previewed through the filter mask; commit then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `view`, `commit` and -`cancel`. All parts of one request are validated before any is applied; a rejected request +`cancel`, and while a raw file opens also `demosaic` (below). All parts of one request are validated before any is applied; a rejected request leaves the settings, view state, preferences and document unchanged (and closes a dialog it opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / `cancel`, and `commit` together with `cancel` are errors. Point curves (`pointCurve`, `pointCurveRed`, @@ -478,14 +486,20 @@ keeps the dialog open for correction. Nothing else writes document history. **Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the command line) of a camera raw developed from its sensor data shows this dialog first, titled "Camera Raw (name)", with **Open** and **Cancel**, as Photoshop opens raws in Adobe Camera Raw. -`ui.inspect.cameraRaw.openingRaw` is then `{name, path}`. Commit (Open) re-develops the raw when +`ui.inspect.cameraRaw.openingRaw` is then `{name, path, demosaic}`, `demosaic` being the method +the raw is to be opened with (`ahd`, `mhc` or `bilinear`): at first the one the open developed it +with, then whatever `ui.demosaic` or the Demosaic menu at the top of Detail chose. `ui.demosaic` +is accepted only while a raw file opens (an error otherwise) and an unknown method is refused. +Commit (Open) re-develops the raw when the demosaic method differs from the one the open used or Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or Dehaze changed (as-shot white-balance gains, develop exposure, and the other five in linear light before the camera's tone curve, as Camera Raw does for raw files; without an as-shot white balance, or without the file's bytes, they stay RGB adjustments), applies the remaining settings as one `filter.cameraRaw` step and leaves the -document unmodified; its result is `{document, redeveloped, filter?}`. Cancel closes the -document. `app.open` and other automation opens never show the dialog. The preference -`rawDefaults.openInCameraRaw` (default `true`) turns it off. +document unmodified; its result is `{document, redeveloped, filter?}`. The preview keeps the +method the file was opened with. Cancel closes the document. `app.open` and other automation +opens never show the dialog. The preference `rawDefaults.openInCameraRaw` (default `true`) turns +it off; `rawDefaults.demosaic` (`ahd` by default, `mhc`, `bilinear`; Preferences › Camera Raw) +is the method the app's raw opens develop with (interactive opens, `app.open` and Place). Imported PSD Camera Raw filters whose processing settings are all mapped or neutral use this same editor. `params.__cameraRawPsd` is reserved import/export metadata: preserve it when editing diff --git a/docs/development.md b/docs/development.md index 4e450daffe..fd5512b710 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,6 +61,37 @@ cargo test --doc --workspace # nextest does not run doctests [cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. +### Shortcut capture without a GPU, and native snapshots + +The ordinary `shortcut_capture_tests` check modifier capture, conflicts, restored bindings and +visible labels without starting a GPU renderer. `captured_shortcut_gpu_snapshots` repeats the Ctrl+K +capture assertions at 1x and 2x with a native renderer when `PHOTOCRAFT_SHORTCUT_SNAPSHOTS` names +an output directory. Its `gpu` test name selects the existing Nextest GPU group. It also holds +`photocraft_testkit::gpu_lock`, requires successful rendering and +checks physical image dimensions before saving both PNGs. As with the Camera Raw localization +snapshots, pixel appearance is reviewed from the saved images rather than a golden-image test. + +On a native host with a working wgpu adapter, capture the two images into a fresh directory: + +```sh +( + set -eu + shortcut_snapshots="$(mktemp -d)" + PHOTOCRAFT_SHORTCUT_SNAPSHOTS="$shortcut_snapshots" cargo test -p photocraft-ui-egui --lib \ + prefs_ui::shortcut_capture_tests::captured_shortcut_gpu_snapshots -- --exact + test -s "$shortcut_snapshots/ctrl-k-1x.png" + test -s "$shortcut_snapshots/ctrl-k-2x.png" + printf 'Review the shortcut row and conflict message in %s\n' "$shortcut_snapshots" +) +``` + +The file checks intentionally reject a successful zero-test selection. Keep the fresh directory +until both scales have been inspected: the row must show the captured shortcut without a stray ControlLeft +key name, and the conflict message must remain readable. Rendering and file-write failures fail +the opted-in test; an unset variable leaves the ordinary behavior tests available without a GPU. +After review, remove only the two generated PNGs and their owned temporary directory. The same +Rust test and variable work on Windows with the native shell's environment-variable syntax. + ## Fonts (craft-fonts) Font assets shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo: don't commit font files here (Inter and JetBrains Mono in `assets/fonts/` are the only exceptions; new fonts go to craft-fonts). The rules are in `craftrules/standards/fonts.md` in a sibling `craftrules` checkout (see below); that repository is not public, so outside contributors can ask a maintainer for the rules that apply to their change. @@ -82,11 +113,11 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa ## One instance per user -On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.lock` in the config directory and listens on a loopback port named in `instance.json` (with a fresh 256-bit token; the file is 0600 on Unix). A later launch hands its file arguments there and exits, so files opened from Explorer or a file manager become tabs in the running window (#3708). The primary answers `ok` only once its UI has taken the files; a launch that gets anything else (the primary is starting, hung or closing) keeps retrying the lock for up to 10 s, becoming the primary itself once the old one lets go, and then starts a separate instance, so a file is never acknowledged and lost. When the event loop ends (and before the CPU-compatibility relaunch) the primary refuses new files and frees the lock. It applies only with the per-user settings folder: a portable copy (`PhotoCraftData` beside the executable) or `PHOTOCRAFT_CONFIG_DIR` may be shared between users, so those launches run separately. So do `--new-instance` (or `PHOTOCRAFT_NEW_INSTANCE=1`), every `--control` launch, and launches given a per-process option (`--safe-gpu`, `--present-mode`, `--frame-latency`, `--stroke-trace`, `--in-window-menus`, `--automation-*-root`). A `cargo run` while an installed PhotoCraft is running forwards to it: pass `--new-instance` or set `PHOTOCRAFT_CONFIG_DIR`. macOS gets the same behaviour from LaunchServices (`apple_events.rs`). On native Wayland the running window can't bring itself forward (that takes the launcher's `XDG_ACTIVATION_TOKEN`, and winit 0.30 can't activate an existing window with one), so it asks for attention instead and the taskbar highlights it; X11, XWayland and Windows bring it to the front. Flatpak needs its own follow-up (#1540). Code: `apps/photocraft/src/single_instance.rs`. +On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.lock` in the config directory and listens on a loopback port named in `instance.json` (with a fresh 256-bit token; the file is 0600 on Unix). Inside a Flatpak it listens on a Unix socket instead, `$XDG_RUNTIME_DIR/app//instance.sock` (0600), because Flatpak isolates each sandbox's network namespace, loopback included, unless the app is granted `--share=network`, while that runtime directory is shared by all sandboxes of the app (#1540); the lock, token and protocol are the same. A later launch hands its file arguments there and exits, so files opened from Explorer or a file manager become tabs in the running window (#3708). The primary answers `ok` only once its UI has taken the files; a launch that gets anything else (the primary is starting, hung or closing) keeps retrying the lock for up to 10 s, becoming the primary itself once the old one lets go, and then starts a separate instance, so a file is never acknowledged and lost. When the event loop ends (and before the CPU-compatibility relaunch) the primary refuses new files and frees the lock. It applies only with the per-user settings folder: a portable copy (`PhotoCraftData` beside the executable) or `PHOTOCRAFT_CONFIG_DIR` may be shared between users, so those launches run separately. So do `--new-instance` (or `PHOTOCRAFT_NEW_INSTANCE=1`), every `--control` launch, and launches given a per-process option (`--safe-gpu`, `--present-mode`, `--frame-latency`, `--stroke-trace`, `--in-window-menus`, `--automation-*-root`). A `cargo run` while an installed PhotoCraft is running forwards to it: pass `--new-instance` or set `PHOTOCRAFT_CONFIG_DIR`. macOS gets the same behaviour from LaunchServices (`apple_events.rs`). On native Wayland the running window can't bring itself forward (that takes the launcher's `XDG_ACTIVATION_TOKEN`, and winit 0.30 can't activate an existing window with one), so it asks for attention instead and the taskbar highlights it; X11, XWayland and Windows bring it to the front. Code: `apps/photocraft/src/single_instance.rs`. ## Graphics startup and device loss -`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12: when DXGI lists an Intel adapter, the app creates its wgpu instance with DX12 and GL only, so the first start doesn't load the Intel Vulkan driver, and a discrete GPU beside the Intel one also runs on DX12. Help › System Info shows the adapter, backend, driver and fallback state. +`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel and Qualcomm (Windows on ARM) adapters on Windows use DX12; when DXGI lists an Intel adapter, the app creates its wgpu instance with DX12 and GL only, so the first start doesn't load the Intel Vulkan driver, and a discrete GPU beside the Intel one also runs on DX12. Help › System Info shows the adapter, backend, driver and fallback state. On Linux, on a machine with several GPUs (say an eGPU, a discrete laptop GPU and an iGPU), `auto` takes the first discrete adapter the driver lists; choose another in Preferences › Performance › Advanced › GPU adapter (`performance.gpuAdapter`, Linux; a case-insensitive piece of the adapter's name, empty is automatic), or for one launch with `PHOTOCRAFT_GPU_ADAPTER=nvidia`. An adapter that is missing or can't present to the window falls back to the automatic choice, with a note in System Info. @@ -99,6 +130,8 @@ keep the marker for recovery. If the device is lost while running (#243), every GPU entry point checks the device's health flag first, the canvas switches to the CPU compositor for the rest of the session and a notice says "GPU device was lost; using the CPU renderer." `ui.gpu.simulateLoss` triggers this path from the control channel. +The window itself draws with the same device (or, with the CPU canvas, one it watches), and nothing can draw it once wgpu reports that device lost, for example after a driver reset while the PC slept (#3438). A frame with shapes then panicked inside egui-wgpu ("Failed to create staging buffer", #1215). A frame without any froze the window, and the close button opened an unsaved-changes prompt nobody could see. `gpu_status::check_window` polls the device each frame. Once wgpu reports it lost, frames draw nothing, every unsaved document is written to crash recovery (`Services::flush_autosaves` waits for the writes), and the app closes; the next launch reopens them. `ui.gpu.simulateLoss {destroy: true}` destroys the real device to test this path. + ## Logs The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. A launch while another PhotoCraft is still running leaves that instance's log where it is and writes its own `photocraft-.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft , log file ` line at startup names the file in use). The file stops growing at 16 MiB. `--version` and command-line errors write no file. @@ -122,6 +155,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback | | `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path | | `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) | +| `PHOTOCRAFT_TEXEL_GUARD=1` (or `=0`) | Force on (off) uploading egui's fully transparent texels as alpha 1/255. On by default only for Intel Iris Xe on DX12, whose driver shows stale memory in all-zero texture blocks: red squares in brush previews and tool icons (`ui-egui/src/texel_guard.rs`; check a GPU with `cargo run --release -p photocraft-gpu --example zero_block_probe -- dx12 400`) | | `PHOTOCRAFT_FX_NOCACHE=1` | Bypass the CPU layer-effect map cache (`compose::effect_maps`) | | `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) | | `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). | @@ -201,6 +235,7 @@ cargo run -p photocraft-cli -- commands --filter blur # the c - `manifest.json`: the versioned document tree, with migrations in `format/src/migrate.rs`. - `tiles/.zst` and `blobs/.zst`: zstd-compressed objects, content-addressed by the BLAKE3 hash of their uncompressed bytes. - `thumb.png` and `composite/preview.png`: previews. +- `save.lock` (directory bundles only): an empty file a save holds an OS file lock on, so two writers or processes saving one bundle take turns (#4957). Keep one `PcraftWriter` per open document: re-saving then only compresses and writes tiles that changed. Directory bundles verify objects on first encounter in a folder; later saves reuse them while their file size and modification time are unchanged, and re-verify changed objects, repair missing or damaged objects, and garbage-collect unreferenced ones. `format::Autosaver` writes snapshots into a recovery directory on a background thread. `list_recovery` / `recover` / `discard_recovery` implement crash recovery, and `format::RecoveryStore` is the lifecycle the desktop app uses: new documents autosave under per-launch keys (document ids restart every launch, so they never overwrite an older entry), and a recovered document adopts the entry it came from. That entry is replaced in place by the next autosave and removed only when the document is saved or closed, never just because it was recovered, so a second crash loses nothing. The web build has no crash recovery (no autosave services). @@ -257,7 +292,7 @@ PSD placed layer, the `Idnt` uuid its duplicates share), shared contents (`smart every copy of the same contents has, and the number of `instances`; Replace Contents changes them all and lists them in its result), type text, adjustment settings, channels and history, so agents can verify what they did without a -screenshot. `crates/automation/tests/agent_tasks.rs` is the reference: ten realistic edit tasks +screenshot. `crates/automation/tests/it/agent_tasks.rs` is the reference: ten realistic edit tasks (title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align, capability-scoped export, resize/crop, CMYK + native save) driven purely over MCP. @@ -349,7 +384,7 @@ cargo xtask perf --update-baseline # also write perf/baseline.json from this r - `perf/baseline.json`: the numbers the scorecard shows, written only by `cargo xtask perf --update-baseline`, with the machine (CPU, RAM, GPU adapter, OS), its machine class, commit, date and load average. -- `crates/io/tests/corpus.rs`: the corpus floors (every `Source { .. }` constant). +- `crates/io/tests/it/corpus.rs`: the corpus floors (every `Source { .. }` constant). - The prefs audit: `Preferences` fields that no code reads (target 0, #204), plus counts taken from the tree (never-crash attribute coverage, `docs/parity-checklist.md`). @@ -365,6 +400,40 @@ different machine classes are never compared, and `--quick` numbers are only com quick baseline. Machines that run other work give noisy numbers: check the load average in the summary before trusting a regression, and record it next to any number you quote. +**Measuring one scenario.** A full run is tens of minutes; one row is seconds once its bench is +built. `xtask perf`'s own flags are `--quick`, `--update-baseline`, `--threshold`, `--bench` +(repeatable, one bench per name), `--skip-build` and `--reuse`; it has no way to pick a single +*row*. The bench binary takes those filters itself — `--only ` (row names are matched +with `contains`), `--group ` and `--reps N` — so to time one row run the bench directly: + +```sh +cargo run --release -p photocraft-ui-egui --example perf_scenarios -- \ + --only "Select > Modify > Smooth" --json target/perf/p26.json +``` + +That writes the row's own report and stops there: it does **not** evaluate the budget, so read +`p50_ms`/`p95_ms`/`max_ms` out of the JSON yourself. Use `cargo xtask perf --bench ` when you +want the verdict, the merged `results.json`/`summary.md` and the regression check. + +Read the sample count before quoting a percentile. `--reps` defaults to 7 in `--quick` and 15 +otherwise, it is per bench, and some rows cap it themselves (the `#211` algorithm rows use +`b.reps.min(5)`); percentiles are nearest-rank, so **at n ≤ 5 the "p95" is simply the maximum**. A +budgeted scenario the baseline has no row for fails the run (`missing` is in `Status::fails()`), +so add the `[[scenario]]` and its baseline row together. + +**A red `cargo xtask perf` is not always your budget.** The committed baseline carries +`failed.P12` (a wgpu panic writing `pc_compose_uniforms`), so the command exits non-zero before +any of this is about your change: read the row, not the exit code. And do not read the committed +verdicts as a clean-machine measurement: `perf/baseline.json` was written on 2026-10-05 at a load +average near 225 on a 14-core Apple M4 Pro, which is why P26 and P27 are over budget there while +`perf/budgets.toml` records 20–45 ms p95 for P26 on a clean 16-core machine (`enforce = false` +until CI cores are measured). Quote a number with the load average and the machine class beside it. + +**Flipping `enforce`.** `enforce = true` makes the nightly fail whenever that budget breaks, and a +baseline file belongs to exactly one machine class. Flip the row and refresh the baseline on the +nightly's own class in the same change, or `docs/scorecard.md` turns "BUDGET BROKEN" from numbers +nobody measured on that machine. + **Adding a scenario.** Add a row to a bench (keep its name stable: it is the key), give the bench `--json` support through `photocraft_testkit::perf::{row, report, write_report}`, then add a `[[scenario]]` to `perf/budgets.toml` and run `cargo xtask scorecard`. @@ -460,8 +529,14 @@ recorded) when adding an event to the table: ```sh cargo run -p photocraft-psd --example atn_dump -- "Default Actions.atn" +cargo run -p photocraft-psd --example atn_roundtrip -- "Default Actions.atn" # parse, write, diff ``` +The real-file oracles are `corpus/atn/` (16 sets from seven public repositories, see *Test +corpora*): `photocraft-psd`'s corpus test parses every file and rewrites it byte for byte; +`photocraft-engine`'s loads, saves and reloads every set and caps the steps that still have no +PhotoCraft equivalent. + Events outside the table load as `actions.photoshopStep` steps that keep the descriptor verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. @@ -496,11 +571,13 @@ against committed **sha256 manifests**. All pins are in one place: | Directory | What | Source | Manifest | |---|---|---|---| | `corpus/photoshop/` | 256 PSDs we authored with Photoshop: smart filters, layer-style effect shapes, the text engine, adjustments in every mode and depth | https://github.com/storytold/photocraft-corpus (ours, MIT OR Apache-2.0) | `xtask/photoshop-corpus.sha256` | +| `corpus/gimp/` | 14 XCF files written by GIMP 3.2.6 with GIMP's merged rendering of each as a PNG (28 files), the oracles of the XCF reader | https://github.com/storytold/photocraft-corpus (ours, MIT OR Apache-2.0; its own pin) | `xtask/gimp-corpus.sha256` | | `corpus/psd/` | 170 small psd-tools and ag-psd files, the mix most PSD tests use | psd-tools and ag-psd upstreams (MIT) | `xtask/psd-corpus.sha256` | | `corpus/psd-tools/` | the complete psd-tools test set (309 files) | psd-tools upstream (MIT) | `xtask/psd-tools-corpus.sha256` | | `corpus/heif/` | 9 small HEIC/HEIF files (checkerboards, RGB strips, a grid-tiled photo with EXIF/XMP, each with Apple's decode as `.ref.png`; a 10-bit RGBA file with its source PNG), for the `heif` feature | heic-rs (MIT OR Apache-2.0) and pillow-heif (BSD-3-Clause) upstreams | `xtask/heif-corpus.sha256` | | `corpus/exr/` | the 5 deep OpenEXR test images (scanline deep data with half colour and u32 ID channels; 2.3 MB), checked against the ID manifests of their upstream sidecars | OpenEXR upstream at v3.5.2 (BSD-3-Clause) | `xtask/exr-corpus.sha256` | | `corpus/affinity/` | 21 public Affinity documents and the 18 Affinity 3 samples for #1606, with exported PNG references | vector-art and affinity-samples (CC0), AFDesignLoad, Jac21/Branding and AssetStoreTemplate (MIT) upstreams | `xtask/affinity-corpus.sha256` | +| `corpus/atn/` | 16 Photoshop Actions sets from seven public repositories (frequency separation, adjustments, Save for Web, generative workflows…), the real-file oracles of the `.atn` parser and import | sbaril, bjango, BKWLD, atntools, trimmer, alpha-utility, gif-creator upstreams (MIT, BSD, Unlicense) | `xtask/atn-corpus.sha256` | | `corpus/pngsuite/` | PngSuite | schaik.com release archive (public domain) | (fixed archive) | ```sh @@ -535,13 +612,13 @@ scripts/fetch-corpus.sh # the same as cargo xtask corpus --all and push there. - **Consuming copy:** `corpus/photoshop/` here. It is a plain directory: the pinned snapshot (downloaded as the codeload tarball of the pinned commit, then verified). -- **Local mode:** `cargo xtask corpus --photoshop --local` copies `photoshop/` from +- **Local mode:** `cargo xtask corpus --photoshop --local` copies `photoshop/` (and `--gimp --local` `gimp/`) from `../photocraft-corpus` (or `PHOTOCRAFT_CORPUS_REPO=`) instead of downloading. It warns when the clone's HEAD isn't the pin or its files differ from the manifest. Use it to test regenerated files before pushing: `cargo xtask test-corpus --local` copies them and runs every corpus test. - **Bumping the pin:** commit and push in photocraft-corpus, then in a PR here set - `PHOTOCRAFT_CORPUS_COMMIT` in `corpus_pins.rs`, run - `cargo xtask corpus --photoshop --update-manifest`, commit the manifest diff, run + `PHOTOCRAFT_CORPUS_COMMIT` in `corpus_pins.rs` (`PHOTOCRAFT_CORPUS_GIMP_COMMIT` for `gimp/`), run + `cargo xtask corpus --photoshop --update-manifest` (or `--gimp --update-manifest`), commit the manifest diff, run `cargo xtask test-corpus`, and raise floors that improved. - **Not a submodule or a subtree:** a subtree would put the binaries back into this repository's history, and submodules cause init and detached-HEAD friction for every contributor. A pin plus a @@ -557,12 +634,12 @@ per source with its own floors: `corpus/psd`, the psd-tools set (also a truncati over every file that must never panic) and our Photoshop set `corpus/photoshop` (per-feature-group totals: `smart-filters`, `effects`, `text`, `adjustments/`). Smart objects and type layers composite Photoshop's cached pixels there; `cargo xtask test-corpus -p engine -- --nocapture` -(`crates/engine/tests/photoshop_oracles.rs`) re-renders them with our smart-filter stack and text +(`crates/engine/tests/it/photoshop_oracles.rs`) re-renders them with our smart-filter stack and text engine and is the failure map for both. A panic is reported as `CRASH` and fails the run. Files without a real merged image (Maximize Compatibility off) are judged against their embedded thumbnail instead (`PASS (thumbnail)`, a strict low-resolution -check); a file only SKIPs when it has no oracle at all. Raise the floors in `crates/io/tests/corpus.rs` when they +check); a file only SKIPs when it has no oracle at all. Raise the floors in `crates/io/tests/it/corpus.rs` when they improve; never lower them. Synthetic reproductions of corpus findings live in -`crates/io/tests/corpus_regressions.rs` (corpus files are never committed). To dig into one file: +`crates/io/tests/it/corpus_regressions.rs` (corpus files are never committed). To dig into one file: ```sh cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/.psd 0 png /tmp/diff.png diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 938a723b69..aa00bdfd4e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 45 filters (31 more: Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,15 +21,15 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | -| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (45 mapped, among them Clouds, Difference Clouds, Fibers and Lens Flare, plus Hue/Saturation, Perspective Warp, Filter Gallery with Chrome and a Camera Raw subset, rest verbatim), slices, comps, Advanced Blending, each layer's XMP packet (`layer.xmpMetadata`). Oracle floors: Photoshop-authored 190/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); generated 20 MP, 150-layer, 2 GB PSD and 4.4 GB PSB fixtures opened, saved and re-opened by io tests/it/large_files.rs (FILE-216-2, 2026-10-13; the 4.4 GB PSB opens on Windows); > 4 GB from the app's File › Open on Windows: #375 | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders, including Photoshop mixed-order type blocks (#4758); LZW/ZIP for scripted saves by `tiffCompression`; undecodable type keeps saved pixels (FILE-215-7: extra alpha channels not written) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); File › Save, Save As and Save a Copy ask JPEG Options: Quality 0–12, starting at the opened file's quality or else the Export preference, and Baseline ("Standard") / Baseline Optimized / Progressive; scripts pass `quality`/`jpegFormat`, the CLI `--jpeg-format` (FILE-215-19). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web. No Matte, progressive Scans (3–5) or size preview | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | -| BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | +| BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode and Indexed Color documents save as palette BMPs (no transparency) | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399). File › Open keeps an animation's first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the durations (#1198) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -43,11 +43,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB, read-only | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; Cineon read-only, no RLE | -| DICOM | R/W | no | no | | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon: 10-bit gray/RGB | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon: 10-bit log gray/RGB (Save As `.cin`) | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; a Cineon frame saved as Cineon keeps its 10-bit code values; no RLE | +| DICOM | R/W | yes (#1048) | no | Read-only, uncompressed syntaxes: MONOCHROME1/2, RGB, YBR_FULL | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | -| Amiga IFF | R/W | no | no | | +| Amiga IFF | R/W | yes | no | Read-only: 1–8 planes with CMAP, stored and ByteRun1 | | Wireless Bitmap | R/W | no | no | | | MPO / JPS (stereo) | R/W | no | no | | | PICT | R | no | no | Legacy | @@ -63,10 +63,10 @@ supports many more, including CR3). | Camera format | PhotoCraft | Evidence | |---|---|---| -| DNG (incl. LJPEG, lossy JPEG and JPEG XL tiles) | yes | 2026-10-13: lossy DNG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, CFA and LinearRaw, partial edge tiles) decodes to the stored samples, which go through LinearizationTable / BlackLevel / WhiteLevel (default 255) like every other depth; `crates/raw/tests/dng.rs` `lossy_jpeg_*` (FILE-215-11, lossy DNG part; no lossy file in the raw corpus yet) | +| DNG (incl. LJPEG, lossy JPEG and JPEG XL tiles) | yes | 2026-10-13: lossy DNG (DNG 1.4 compression 34892: 8-bit baseline JPEG tiles or strips, CFA and LinearRaw, partial edge tiles) decodes to the stored samples, which go through LinearizationTable / BlackLevel / WhiteLevel (default 255) like every other depth; `crates/raw/tests/it/dng.rs` `lossy_jpeg_*` (FILE-215-11, lossy DNG part; no lossy file in the raw corpus yet) | | Canon CR2 | yes | | | Canon CR3 | detected, unsupported | `crates/raw/src/lib.rs:219` | -| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 and D90 image areas measured (14-bit NEFs); [validation](nikon-nef-development.md) | +| Nikon NEF/NRW (lossless, lossy) | yes; "lossy after split" no | #50; D5100 and D90 image areas measured (14-bit NEFs); D4 profile levels follow the sample depth (12 and 14 bit); [validation](nikon-nef-development.md) | | Sony ARW/SR2/SRF (incl. compressed) | yes | | | Panasonic RW2, Pentax PEF | yes | | | Olympus ORF | uncompressed only | | @@ -127,11 +127,11 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Brushes `.abr` | R/W | R/W | `psd/src/abr.rs` (v6/v12) | | Gradients `.grd` | R/W | R/W | | | Patterns `.pat` | R/W | R/W | | -| Swatches `.aco`, `.ase` | R/W | R/W | FILE-215-14 | +| Swatches `.aco`, `.act`, `.ase` | R/W | R/W | FILE-215-14; `.act` also from Image › Mode › Color Table Load…/Save… (`psd/src/act.rs`) | | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (Photoshop events ↔ commands, including Apply Layer Comp by name, #7101; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -142,12 +142,35 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1), the chroma is scaled around the lightness by Photoshop's saturation factor (measured at every whole percent), so colours at or above lightness 1 keep their hue, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) | +| 2026-10-19 | minor | JPEG Options on File › Save, Save As and Save a Copy (FILE-215-19 partial: no Matte, progressive Scans or size preview) | +| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) | +| 2026-10-17 | minor | `.atn`: Apply Layer Comp by name maps to `layerComp.apply` and exports as `applyComp` (#7101) | +| 2026-10-15 | minor | Cineon write: Save As `.cin` stores 10-bit log gray/RGB frames (Cineon 4.5 header, three samples to a 32-bit word); a Cineon frame saved back keeps its code values | +| 2026-10-14 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1) and the slider is cut to whole 8-bit levels, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass | +| 2026-10-14 | minor | Lab documents round each adjustment layer's result on the stored L, a, b to whole samples, as Photoshop stores them (the native Lab path): photoshop lab8 curves.psd passes | +| 2026-10-15 | minor | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | +| 2026-10-16 | minor | PSD smart filters: Perspective Warp (`perspectiveWarpTransform`: `vertices`, `warpedVertices`, `quads` of vertex indices, document pixels) opens as `layer.smartObjects.perspectiveWarp`, one plane per quad, and re-renders warped; save writes the original item with only the geometry rewritten, or a new one in Photoshop's layout; inconsistent descriptors stay verbatim (FILE-215-2, #6268) | +| 2026-10-16 | minor | PSD smart filters: Hue/Saturation (`HStr`: `Clrz`, `Adjs` = master `Hst2` plus one per edited range with `LclR` and its four range sliders, all longs, measured on a Photoshop-saved file) opens as `image.adjustments.hueSaturation`, re-renders with the smart object and saves in Photoshop's layout; layouts outside it stay verbatim (FILE-215-2, #6267) | +| 2026-10-15 | minor | PSD smart filters without options (Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen, Sharpen More, Sharpen Edges, Solarize) map both ways as Photoshop stores them: `hasoptions` false, no `Fltr`, the four-char `filterID` (FILE-215-2) | +| 2026-10-15 | minor | PSD smart filters: Surface Blur (filterID 1135, `surfaceBlur`), Smart Blur (`SmrB`) and Radial Blur (`RdlB`) open as editable filters and save with the keys, types and enums measured in Photoshop; out-of-range, unknown-enum or extra-key entries (an off-centre Radial Blur) stay verbatim (#6298, FILE-215-2) | +| 2026-10-15 | minor | PSD smart filters: Smart Sharpen (filterID 1131, Gaussian removal; Lens/Motion Blur and legacy entries stay verbatim) and Dust & Scratches (`DstS`) open as editable filters and save in Photoshop's measured shape (#6300) | +| 2026-10-15 | minor | PSD smart filters: Diffuse (`Dfs `, `DfsM` mode), Trace Contour (`TrcC`, level and `CntE` edge), Crystallize (`Crst`) and Pointillize (`Pntl`, background from `BckC`) open as editable smart filters and save in the shape measured in Photoshop (#6302, FILE-215-2) | +| 2026-10-15 | minor | PSD smart filters: Wind (`Wnd `, `WndM` method, `Drct` direction), Extrude (`Extr`, `ExtT` type, size, depth, `ExtR` depth mode, solid front faces, mask incomplete blocks) and Tiles (`Tls `, count, offset, `FlCl` fill with the entry's `FrgC`/`BckC`) open as editable smart filters and save in the shape measured in Photoshop (#6314, FILE-215-2) | +| 2026-10-15 | minor | PSD smart filters: Offset (`Ofst`: `Hrzn`/`Vrtc` long, `Fl ` `FlMd` Wrap/Background/Repeat) and Custom (`Cstm`: `Scl `, `Ofst` long, 25-long `Mtrx`) map both ways in `filterFX`, matching descriptors measured in Photoshop (FILE-215-2, #6304) | +| 2026-10-15 | minor | PSD smart filters: Mezzotint (`Mztn`, `MztT` enum, all 10 types) and Color Halftone (`ClrH`, `Rds `/`Ang1`–`Ang4` longs) open as editable filters and save in Photoshop's shape, measured in Photoshop (FILE-215-2); Mezzotint's PhotoCraft-only seed is not saved | | 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | +| 2026-10-15 | minor | Indexed Color documents save as 8-bit palette BMPs with their colour table instead of 24-bit RGB; a palette BMP opens with its declared number of colours, not 256 | | 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) | +| 2026-10-15 | minor | Bevel & Emboss Gloss Contour › Anti-aliased (`antialiasGloss`) is read and written, set by `glossAntiAlias` and shown in the Layer Style dialog; a PSD whose bevel is edited no longer loses it. Rendering still ignores contour anti-aliasing, as for the other effects' contours | +| 2026-10-15 | minor | Stroke › Overprint (`overprint`) is read and written, set by the stroke command's `overprint` and shown in the Layer Style dialog; an edited stroke no longer drops it. Rendering ignores it for now | +| 2026-10-14 | minor | Nikon NEF: the D4 profile's saturation level follows the sample depth (15520 at 14 bits, 3880 at 12), so 12-bit D4 files open as bright as their 14-bit twins instead of 2.6 stops darker (#5978) | +| 2026-10-14 | minor | Animated WebP: New Video Layer from File, Replace Footage, Reload Frame and Video Frames to Layers take every frame, composited, with the timeline rate from the durations (#1198) | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | +| 2026-10-13 | minor | Large-file fixtures generated at test time (20 MP layered, 150-layer layout, 2.02 GB PSD, 4.38 GB PSB), opened, saved and re-opened by io tests/it/large_files.rs; FILE-216-2 done; P20/P63 open times measurable (#5107) | | 2026-10-13 | minor | CMYK Hue/Saturation (not Colorize) and Color Balance work on C, M and Y as inverted RGB with black kept, on the inks for display, export and destructive edits; cmyk8/16 hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25% | | 2026-10-13 | minor | CMYK Posterize works on the stored values (1 − ink), as Photoshop does: psd-tools posterize_cmyk passes (13.8 % → 0 of pixels off on the ink path); psd-tools floor → 242 of 309 | | 2026-10-13 | minor | CMYK documents whose layers are plain raster layers plus Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on their own inks for display, export and flattening: the cmyk8/16 oracles for those seven adjustments pass; Photoshop-oracle floor 151 → 165 of 258 | diff --git a/docs/gaps.md b/docs/gaps.md index ab42d0f8ef..5b5a04413b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -46,12 +46,13 @@ what an agent can't finish alone. constant and transparency is disabled. Variable-range synthesis, HSB/Lab modes and transparency remain unsupported and are preserved verbatim; the five visual examples do not establish Photoshop's exact noise algorithm or include this degenerate constant case. -- **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored - oracles 188/258 (73%; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% +- **Evidence:** corpus floors (`crates/io/tests/it/corpus.rs`, scorecard): Photoshop-authored + oracles 190/258 (74%; 32-bit Hue/Saturation 2026-10-22; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object - blank (#1764), perspective placed layers flattened (#579, fixed by #426), PSB > 4 GB fails on Windows (#375). + blank (#1764), perspective placed layers flattened (#579, fixed by #426), PSB > 4 GB fails on Windows (#375; a generated 4.38 GB PSB opens, saves and re-opens + through io on Windows, io tests/it/large_files.rs, 2026-10-13). No test opens our exports in a third-party editor (FILE-216-4). - **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. - **Estimate:** 150–250 h. **Human:** opening exports in Photoshop on the owner's Mac (or a @@ -70,9 +71,10 @@ what an agent can't finish alone. ### G7. Workspaces shown as parameter dialogs - **Missing:** Select and Mask workspace layout (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area - (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview - of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, - TOOL-214-7, are done). + (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14). Content-Aware Move's live preview + of the result is done (TOOL-213-4, 2026-10-16, #6680: every selection the command takes, coarse + first for large ones, then the exact result), as are Transform On Drop and Content-Aware Scale + through the transform box (TOOL-214-7). Blur Gallery: Field, Iris, Tilt-Shift and Spin Blur are edited on the image with Photoshop's pins and handles (2026-10-11, #3373); Tilt-Shift has one band (the engine takes one), and Path Blur (#1759) is still a parameter dialog. @@ -82,18 +84,29 @@ what an agent can't finish alone. - **Estimate:** 80–140 h. ### G8. Type depth -- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script - fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus - (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview - (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type - (TYPE-218-9), text free transform (#2630). Done since the re-measure: manual and optical kerning - with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985). -- **Evidence:** scorecard Type: 3 done, 2 partial, 4 missing. 2026-10-11: 2/3/4/5 clicks select +- **Missing:** type on a path beyond rigid glyph placement (TYPE-218-1 partial, #4365; the Type tool's click on a shown path landed), + the visual RTL caret and PSD storage of the paragraph direction (TYPE-218-2; the paragraph + direction buttons landed 2026-10-22), complex-script fallback fonts and golden tests + (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus + (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), text free transform + (#2630). Done since the re-measure: manual and optical kerning with PSD round trip + (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985), Warp on live type without a + rasterize prompt, as in Photoshop (TYPE-218-9, #3004), and the live Warp Text preview that opens + with the layer's warp (TYPE-218-7, #2689). +- **Evidence:** scorecard Type: 5 done, 4 partial, 0 missing. 2026-10-11: 2/3/4/5 clicks select word, line, paragraph and all text, with tests (TYPE-218-8, #3493). The Type tool froze the app for 60 s on one macOS machine (#2518). - **Estimate:** 120–200 h. ### G11. Actions and scripting +- **2026-10-21 evidence (#8304):** Save Actions expands compound layer-property edits into + separate Photoshop Set events, with rename last so named layer references remain valid. + Exported descriptors and native playback at RGB 8/16/32-bit have [regression coverage](../crates/engine/src/atn_cmds/layer_props_tests.rs); + independent Photoshop replay of the fix is still pending. No readiness percentage or estimate changed. +- **Done 2026-10-17 (#7101):** `.atn` Apply Layer Comp steps with a named `compsClass` + reference import as `layerComp.apply` and export to the same Photoshop event. Names resolve + in the document being played; missing comps stop playback. Other reference forms stay kept + Photoshop steps and are reported as unsupported. AUTO-219-2 remains partial. - **2026-10-13 evidence:** Batch supports [output naming templates](batch-naming.md) using `{name}`, `{serial}` and a captured UTC `{date}`, with a starting serial number. Colliding names remain errors; custom output names cannot replace inputs. AUTO-219-5 stays partial. @@ -102,6 +115,18 @@ what an agent can't finish alone. mapped events (paths, scripts, Generative Fill, Filter Gallery effects other than Spatter, Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps that fail at playback by name. +- **Done 2026-10-21:** Batch and droplets take each result at the action's recorded Save, Save As + or Save a Copy step, written to the destination folder (Photoshop's "Override Action 'Save As' + Commands"). A document closed after its save is written as the save found it, so actions ending + in Save As and Close no longer fail on every file, and a Close that leaves another document + active no longer saves that one instead. While the saved document stays open, the steps after + the save still apply (#3058). Saves inside a played action, and the CLI's `batch` loop, still + save after the action. +- **Done 2026-10-19:** `.atn` steps that switch to another open document (a `select` of `Dcmn` by + offset: the previous, next or Nth tab from the active one) import as `document.activate {"offset"}`, + play and save back in the same shape, so actions that copy from one open document and paste into + another no longer stop at the switch (#7103). An offset past the first or last open document is + refused, not wrapped. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` and `actions.load`/`actions.save`; 112 Photoshop events map to commands both ways (Transform, Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, @@ -133,10 +158,20 @@ what an agent can't finish alone. tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a measured D5100 colour calibration; [validation](nikon-nef-development.md). +- **Progress (2026-10-14):** a Nikon NEF's embedded preview takes its colour space from the + maker note's ColorSpace (0x001E: 1 sRGB, 2 Adobe RGB) when the EXIF states none, so the + colour fit reads an Adobe RGB preview as Adobe RGB (49 of the 291 public Nikon samples; + Nikon writes no EXIF ColorSpace); `crates/raw/src/preview.rs`. A raw's preview-fallback + document is still opened untagged. - **Progress (2026-10-13):** the D90's image area (4288 × 2848 at (10, 10) in a 4352 × 2868 plane) is measured by aligning the decoded plane with the camera's own JPEG, so a D90 NEF opens on the camera's frame instead of the uncropped sensor plane; `crates/raw/src/cameras.rs` (`IMAGE_AREAS`). +- **Progress (2026-10-14):** the measured Nikon D4 profile's saturation level follows the + sample depth (15520 at 14 bits, 3880 at 12), so a 12-bit D4 NEF opens as bright as the same + scene at 14 bits instead of 2.6 stops darker (#5978). Real CC0 pair, mean linear luminance + against the 14-bit file: 12-bit lossy +0.02, uncompressed -0.02, lossless +0.01 stop; + 14-bit output unchanged (`cameras.rs`, `tests/it/nikon_d4_depth.rs`). - **Estimate:** 120–200 h. ### G15. AI and generative features @@ -145,6 +180,7 @@ what an agent can't finish alone. exist (GrabCut/max-flow selection, Remove tool by non-local patch completion). - **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682). +- **Progress (2026-10-17):** classical Object Finder no longer rejects a foreground solely for covering over 60% of the canvas (#6548); canvas-boundary coverage distinguishes it from the surrounding backdrop. This does not add semantic recognition or close #2788. - **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote). ### G16. Plug-in compatibility @@ -164,6 +200,19 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-13, #3825 partial):** Transfer’s Wetness Jitter and Mix Jitter + blocks are disabled outside the Mixer Brush, retaining their saved settings. Opacity + and Flow keep their existing availability; other layout and brush-feel gaps remain open. +- **Progress (2026-10-14, #3825 partial):** Brush Pose has separate X/Y tilt override + controls. ABR import retains both flags independently instead of combining them; + only the selected component replaces live stylus input. Legacy combined JSON and + command inputs retain their two-axis behavior, and minimal command patches preserve + the other axis. Other Brush Settings and brush-feel gaps remain open. +- **Progress (2026-10-14, #3477 workflow):** section locks expose their section-specific + translated names and locked state to AccessKit. A themed outline follows keyboard focus; + focus changes do not edit settings. Keyboard activation preserves the selected + section and other brush settings, using the existing command path. Other panel and brush-feel + gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -176,8 +225,9 @@ what an agent can't finish alone. - **Missing:** lag with big brushes (#2619, #1994) and under the cursor (#2566, #1343); jumpy pressure on Windows (#2046) and tap sensitivity (#1933); stair-stepped line weights (#1776), interpolation and smoothing problems (#2138), edge falloff (#2149), pixelated cursor vs - Photoshop (#1433), brush rotation not driven by pen rotation (#1301); live preview only for - Brush and Eraser (TOOL-213-7); no image file as a brush tip (TOOL-213-12); per-control pressure + Photoshop (#1433), brush rotation not driven by pen rotation (#1301); no live preview for the + Mixer Brush yet (TOOL-213-7, in PR #3627; the other brush tools draw live, Spot Healing and Remove + compute on release as in Photoshop); no image file as a brush tip (TOOL-213-12); per-control pressure curves (TOOL-213-11); 4 of Photoshop's symmetry modes. - **Detail:** [`brush-parity.md`](brush-parity.md). - **Estimate:** 100–180 h. **Human:** checking feel on real tablets. @@ -199,9 +249,15 @@ what an agent can't finish alone. - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace +- **Progress (2026-10-15):** Preferences › General › Animated Zoom takes effect: a held Zoom + tool press zooms continuously about the press point, ⌥ out (#6210, `ui-egui/src/zoom_hold.rs`). +- **Progress (2026-10-14, #5556):** Character and Paragraph controls stay available without + a type target, editing persisted Type tool defaults inherited by new text. Formatting existing + selected text keeps its current command and undo scope. Other panel gaps remain open. - **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). -- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't +- **Missing:** 24 of 171 preferences do nothing (scorecard prefs audit, 2026-10-15: Save in + Background now takes effect, #6207); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen @@ -216,6 +272,14 @@ what an agent can't finish alone. default small blur and the 36 MP saturated-field gain are inconclusive. [Method and full ranges](tilt-shift-performance.md). Native GUI latency remains unmeasured; no scorecard budget or parity percentage is changed by this work. +- **Measured progress (2026-10-15):** P25, cancelling a Gaussian Blur r 50 job on 24 MP, stops + within 13–44 ms max over five runs (was 337–399 ms; budget 200 ms): the transparency-lock + pass that puts a Background's alpha back now runs per tile in parallel and checks for + cancellation, and column-strip box passes check between passes. On a layer with locked + transparency (a Background) the uncancelled job is ~40% faster (p50 642–735 → 395–409 ms); + other layers gain only the strip checks. Local runs, `perf/baseline.json` not re-measured yet + (the scorecard still shows P25 as not run); command, revision, machine and per-run numbers in + #6206. #6205. - **Missing:** 22 of 25 budgeted scenarios are over budget (baseline 2026-10-05, a heavily loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, @@ -226,15 +290,19 @@ what an agent can't finish alone. ## File-format gaps ### G10. Missing and partial file formats -- **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and its vector/text/multi-page +- **Missing:** JPEG Options' Matte, progressive Scans and size preview (FILE-215-19 partial), Photoshop PDF read and its vector/text/multi-page write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + DICOM, EPS/DCS, Pixar, IFF write (read landed), WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). -- **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the - DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. +- **Progress (2026-10-15):** Cineon (`.cin`) 10-bit gray/RGB frames open and save (Save As + `.cin`), sharing the DPX header/packing (`crates/codecs/src/codecs/dpx.rs`); a Cineon frame saved + back keeps its code values. +- **Progress (2026-10-13):** Amiga IFF/ILBM (`FORM ... ILBM`/`PBM `) 1–8-plane paletted bitmaps + open read-only, stored or ByteRun1-compressed (`crates/codecs/src/codecs/iff.rs`); writing is + still open. - **Estimate:** 120–220 h. ## Hardware gaps @@ -258,6 +326,10 @@ what an agent can't finish alone. as tofu (#1408, #1693). - **Evidence (2026-10-11):** Czech dynamic UI wrappers, hardware labels and composed messages translate at display sites; engine errors remain English. This does not close G13. +- **Evidence (2026-10-15):** Russian gained the 1,229 dynamic labels the other catalogs already + had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, + metadata, System Info, status templates, dialogs), with a terminology note + ([localization-ru](localization-ru.md)); engine errors remain English. This does not close G13. - **Detail:** [`localization-parity.md`](localization-parity.md). - **Estimate:** 80–140 h. **Human:** native-speaker review of every catalog (none is recorded). @@ -274,11 +346,33 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | G1: 32-bit Hue/Saturation unclipped with Photoshop's saturation factor, bright colours keep their hue, Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass; Photoshop-oracle floor 188 → 190 of 258 (#5781) | +| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | +| 2026-10-21 | minor | G11: Batch and droplets take the result at the action's recorded Save / Save As step (Photoshop's "Override Action 'Save As' Commands"); estimate unchanged | +| 2026-10-22 | minor | G8: Auto / Left-to-right / Right-to-left paragraph direction buttons in the Paragraph panel, Properties and the Type options bar (#6793); TYPE-218-2 partial (the visual RTL caret is still missing, and PSD doesn't store the direction yet) | +| 2026-10-19 | minor | G10: JPEG Options dialog landed (FILE-215-19 partial: no Matte, progressive Scans or size preview); no readiness percentage or estimate changed | +| 2026-10-19 | minor | G11: `.atn` steps that select another open document by offset import as `document.activate {"offset"}`, play and save back (#7103); estimate unchanged | +| 2026-10-18 | minor | G13: Russian gained the 1,229 dynamic UI labels other catalogs already had (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs); engine messages and native review remain open | +| 2026-10-17 | minor | Classical Object Finder improvement; learned segmentation remains open | +| 2026-10-15 | minor | G10: Cineon write landed (10-bit log gray/RGB, Save As `.cin`) | +| 2026-10-14 | minor | G1: 32-bit Hue/Saturation unclipped (slider on whole levels) and Colorize's saturation scaled by 1 − \|Lightness\|; photoshop rgb32 hue-saturation and hue-saturation-colorize pass | +| 2026-10-14 | minor | G1: Lab adjustment layers' results are whole samples (native Lab path); photoshop lab8 curves passes | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | +| 2026-10-17 | minor | G11: `.atn` Apply Layer Comp imports, plays and exports by comp name (#7101); other reference forms remain unsupported | +| 2026-10-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms; local runs, baseline not re-run), the uncancelled job on a locked-transparency layer about 40% faster; #6205 | +| 2026-10-15 | minor | G9: Animated Zoom preference takes effect (hold the Zoom tool to zoom continuously, #6210); prefs that do nothing 25 of 166; no estimate changed | +| 2026-10-16 | minor | G7: Content-Aware Move's live preview covers every selection the command takes (over 256 × 256: a coarse result solved scaled down, then the exact one); TOOL-213-4 done (#6680) | +| 2026-10-15 | minor | G9: Save in Background off saves in the foreground under a modal progress dialog (#6207); preferences that do nothing 25 → 24 of 169 | +| 2026-10-14 | minor | G14: the Nikon D4 profile's white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark (#5978); gap remains open | +| 2026-10-14 | minor | G4: independent Brush Pose X/Y overrides across UI/import/painting/commands (#3825 partial); legacy combined input retained; no readiness percentage or estimate changed | +| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state and visible keyboard focus (#3477 workflow); no readiness percentage or estimate changed | +| 2026-10-13 | minor | G4: Transfer Wetness/Mix tool applicability (#3825, partial); no readiness percentage or estimate changed | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | +| 2026-10-14 | minor | G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E); gap remains open | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | +| 2026-10-13 | minor | G1: generated large-file fixtures (20 MP, 150 layers, 2 GB PSD, 4.38 GB PSB) round-trip in io tests/it/large_files.rs; the 4.38 GB PSB opens on Windows through io (#375 evidence); FILE-216-2 done (#5107) | | 2026-10-13 | minor | G1: CMYK Hue/Saturation and Color Balance work on C, M and Y as inverted RGB with black kept, as Photoshop does; cmyk hue-saturation 100% → 0.4–0.7% bad pixels, color-balance 74% → 25%; floor unchanged | | 2026-10-13 | minor | G1: CMYK Posterize works on the stored values (1 − ink); psd-tools posterize_cmyk passes; psd-tools floor → 242 of 309 | | 2026-10-13 | minor | G1: CMYK stacks with Invert, Posterize, Threshold, Brightness/Contrast, Levels or Curves layers composite on the document's own inks (14 CMYK oracles pass); Photoshop-oracle floor 151 → 165 of 258 | @@ -293,7 +387,10 @@ what an agent can't finish alone. | 2026-10-13 | minor | G1: a pass-through group that is a clipping base and holds an adjustment layer renders isolated; psd-tools passthrough_clipping_mask_adjustment passes; psd-tools floor → 240 of 309 | | 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 | | 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | +| 2026-10-14 | minor | G9: Character/Paragraph defaults before creating text (#5556); no percentage or estimate changed | | 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | +| 2026-10-13 | minor | G10: Amiga IFF/ILBM read landed (1–8 planes, CMAP, stored and ByteRun1); IFF write remains open | +| 2026-10-13 | minor | G8: a Type tool click on the work path or the active shape layer's path starts type on it at the point clicked (TYPE-218-1, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | @@ -305,6 +402,7 @@ what an agent can't finish alone. | 2026-10-13 | minor | G10: Cineon read landed (10-bit gray/RGB, shares the DPX path); Cineon write remains open | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | +| 2026-10-11 | minor | G8: the live Warp Text preview (TYPE-218-7) moved to done; TYPE-218-9 listed as done; Type evidence recounted to 5/3/1 (TYPE-218-1 type on a path is partial on main) | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | | 2026-10-11 | minor | G7: Tilt-Shift's band (lines, turn dots) and Spin Blur's pins (outline handles, blur-angle ring) on the image; Path Blur remains a dialog | @@ -313,6 +411,7 @@ what an agent can't finish alone. | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | +| 2026-10-11 | minor | G4: Pattern Stamp and Background Eraser draw live, Spot Healing shows a dark overlay while dragging (#3754); only the Mixer Brush is left of TOOL-213-7 | | 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing | | 2026-10-11 | minor | G10: one-page flattened PDF write shipped (io::pdf, #3401); FILE-215-10 now partial | | 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open diff --git a/docs/kra.md b/docs/kra.md index 847a1f32d2..19e39bcf1e 100644 --- a/docs/kra.md +++ b/docs/kra.md @@ -54,8 +54,8 @@ corrupted files are covered by tests. Unit tests (`crates/io/src/kra/tests.rs`) build Krita archives in code (raw and LZF tiles) and check layers, groups, pass-through, offsets, opacity, visibility, locks, masks, default pixels, 16-bit and gray conversion, the merged-image fallback, LZF edge cases and malformed input. -`crates/automation/tests/krita.rs` opens a `.kra` through the agent surface, edits it and saves -it as OpenRaster. `crates/engine/tests/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a +`crates/automation/tests/it/krita.rs` opens a `.kra` through the agent surface, edits it and saves +it as OpenRaster. `crates/engine/tests/it/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a session, checks it is read-only, edits with undo and saves as `.ora` and `.pcraft`. Real-file checks use the Krita-authored oracles in @@ -64,8 +64,8 @@ Real-file checks use the Krita-authored oracles in the files are sRGB): ```sh -KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture -KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test kra -- --ignored +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test it -- kra:: --ignored ``` 2026-10-09, Krita 5.2.9: 31 of 35 `.kra` files render within 3/255 of Krita's merged image; the diff --git a/docs/localization-it.md b/docs/localization-it.md index 7b0444410e..f983c499e4 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -44,9 +44,18 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast | HDR Toning | Mappatura tonale HDR | tone mapping; *Viraggio* stays for colour toning (*Viraggio separato*) | | Bevel & Emboss | Smusso e rilievo | pairs with *Rilievo* (Emboss) | | Linked (smart object) | Oggetto collegato | *Inserisci oggetto collegato*, *Converti in oggetto collegato* | +| Screen (blend mode) | Scherma | the blend mode; *Schermo* stays for the display (*Modalità schermo*, *Risoluzione schermo*) | +| Dodge / Burn / Sponge (tools) | Scherma / Brucia / Spugna | the *tools*, one family. *Scherma* is also the `Screen` blend mode (Adobe's own collision). *Schiarisci* is the `Lighten` blend mode and is never the Dodge tool | ## Native review +2026-10-13: `Screen` was *Schermo* — the display, not the blend mode — and is now *Scherma*, the +word Adobe uses. The Dodge/Burn/Sponge tools were left as they were (*Scherma*, *Brucia*, *Spugna*): +a first pass also wanted to rename the Dodge tool to *Schiarisci*, but that is the `Lighten` blend +mode and would have given two different things the same name. The wording is pinned by a test +(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which +also keeps the tool family together. + 2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, bit depth labels). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 0b2be85595..9f5d6cd066 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Spanish dynamic labels; previously minor: Serbian catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (Arabic: paragraph direction buttons, #6793; previously minor: Russian dynamic labels) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), -[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md). +[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md), [Russian](localization-ru.md). **How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog @@ -33,8 +33,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde | English | `en` | 1,608 / 1,608 (100%) | source | all | Latin | – | full | 0 h | | Simplified Chinese | `zh-hans` | 1,608 / 1,608 (100%) | 2,366 | dialogs, tooltips yes; engine messages English | CJK fonts (lazy loader), IME preedit/commit, vertical type | no | partial | 8–12 h | | Spanish | `es` | 1,844 / 1,844 (100%) | 4,010 | as above; complete dynamic UI key set | Latin | no | partial | 6–10 h | -| Hindi | `hi` | 0 (0%) | – | – | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | none | 20–30 h | -| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction has no UI (TYPE-218-2), layer names as tofu (#1408, #1693) | no | none | 40–60 h | +| Hindi | `hi` | partial | 448 | Top menus, toolbox tools, camera raw, dialogs, plurals | Devanagari shaping in the text engine (parley/HarfRust); no Devanagari fallback font (TYPE-218-3) | no | partial | 15–20 h | +| Arabic | `ar` | 0 (0%) | – | – | Arabic shaping and bidi in the text engine, Arabic fallback font; RTL shell layout but not inside panels, and UI labels in LTR word order (DIST-220-4), paragraph direction buttons but no visual RTL caret (TYPE-218-2 partial), layer names as tofu (#1408, #1693) | no | none | 40–60 h | | French | `fr` | 1,608 / 1,608 (100%) | 2,766 | as above | Latin | no | partial | 6–10 h | | Portuguese (Brazil) | `pt-br` | 1,608 / 1,608 (100%) | 2,788 | as above | Latin; every `pt` locale uses it | no | partial | 6–10 h | | Indonesian | `id` | 1,608 / 1,608 (100%) | 2,764 | as above | Latin | no | partial | 6–10 h | @@ -46,8 +46,8 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde ## Other shipped languages 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional -Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,008; also every dynamic label the +Chinese `zh-hant` (2,329 rows), Russian `ru` (4,227 keys; also every dynamic label the +other catalogs translate, [notes](localization-ru.md)), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (4,164; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every @@ -87,3 +87,6 @@ engine localization or native review is complete. | 2026-10-12 | minor | Spanish: 1,276 runtime-only labels added (filter and adjustment parameters, Camera Raw, panels, dialogs and status wrappers); generated-label coverage test added | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | | 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | +| 2026-10-13 | minor | Italian: the `Screen` blend mode read *Schermo* (the display) and is now *Scherma*; the Dodge/Burn/Sponge tool names were already Adobe's (*Scherma*, *Brucia*, *Spugna*) and stay. Pinned by a test | +| 2026-10-18 | minor | Russian: 1,229 dynamic labels added (filter and adjustment parameters, Camera Raw, lighting, History/Layers/paths menus, metadata, System Info, status templates, dialogs) with a terminology note ([localization-ru](localization-ru.md)) | +| 2026-10-22 | minor | Arabic row: paragraph direction has Auto / Left-to-right / Right-to-left buttons in the Paragraph panel and Type options bar (#6793); the visual RTL caret is still missing (TYPE-218-2 partial) | diff --git a/docs/localization-ru.md b/docs/localization-ru.md new file mode 100644 index 0000000000..ea0eff64df --- /dev/null +++ b/docs/localization-ru.md @@ -0,0 +1,75 @@ +# Russian terminology and dynamic UI labels + +This note goes with the completion of the Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`). +The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked the +labels that only exist at runtime and that other catalogs (Italian, Serbian, Czech, Hungarian) +already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects, +History / Layers / paths / channels menu labels, metadata fields, status messages, System Info +and dialog text. In Russian those showed in English. +They are now translated, reusing the vocabulary the catalog already uses elsewhere so the same +term reads the same everywhere. Translations are written from the meaning of PhotoCraft's English +labels; no proprietary translation resources, fonts, screenshots or user documents are included. + +## Register and style + +Commands are imperatives in the second person plural (*Сохранить*, *Удалить*, *Заново*); +where the catalog already uses a noun it is kept (*Уровни…*, *Кривые…*). Sentence case after the +first word, as in the rest of the catalog. Ellipses on dialog commands are preserved (`Ветер…` +→ `Ветер…`). Decimals use a comma (the file format's own labels keep their dots, e.g. +`Landscape, 10 x 8` stays `Альбомная, 10 x 8`). Latin product and technology names are not +translated (PhotoCraft, Camera Raw, RGB, CMYK, Lab, LUT, WebP); Camera Raw keeps `.xmp`; the +`HDR`, `HSL`, `HSB` and `W3C` acronyms and the `sGray`/`Pro Photo` identifiers stay Latin. + +## Terminology + +Existing choices were followed rather than re-invented, so the new rows use: + +| English source | Russian | Note | +|---|---|---| +| Highlight(s) | Света | already in the catalog (`Highlights` → `Света`) | +| Midtone(s) | Полутона | already in the catalog | +| Shadow(s) | Тени | pairs with *Света* | +| Feather | Растушёвка | never *перо* | +| Fuzziness | Разброс | Color Range's tolerance | +| Dither | Дизеринг | | +| Pattern | Узор | also in *Узорный штамп* | +| Preset | Набор | *Набор кистей*, *набор инструментов* | +| Guide | Направляющая | *Схема направляющих* | +| Artboard | Монтажная область | | +| Workspace | Рабочая среда | | +| Stroke (contour) | Обводка | painterly strokes stay *штрихи*/*мазки* | +| Stroke (paint param) | Штрихи / мазки | `Strokes` → `Штрихи` | +| Amount | Величина | `Штрихи`, never *количество*, when it is a strength | +| Fill Opacity | Непрозрачность заливки | distinct from layer *Непрозрачность* | +| Feather Radius | Радиус растушёвки | | +| Sharpen | Резкость | `Smart Sharpen` → *Умная резкость*, `Unsharp Mask` → *Контурная резкость* | +| Noise | Шум | `Reduce Noise` → *Уменьшить шум* | +| Content-Aware | С учётом содержимого | | +| Camera Raw | Camera Raw | the panel name stays Latin (as German/Spanish do; Greek translates it) | +| Place Embedded / Linked | Поместить встроенный / связанный | | +| Rasterize | Растрировать | | + +## Native review + +The review was AI-assisted and each row was checked against the meaning of PhotoCraft's own +English label. A review by a native speaker is still welcome. + +## Not covered + +Engine error and status messages, user document, layer and preset names, and automation command +IDs stay in English, as in every language ([`localization-parity.md`](localization-parity.md)). +A few dynamic labels the other catalogs carry are not used by the current code; they are +translated to stay aligned with the other catalogs. + +## Validation + +- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) + enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires + a Russian row for each, like the Korean and Italian tests. +- `russian_dynamic_status_hardware_and_dialog_labels_are_translated` (`crates/ui-egui/src/i18n/mod.rs`) + covers the rasterize prompts, hardware status lines, composed status templates and a sample of + filter labels. +- `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed. +- `cargo xtask i18n-coverage`: `ru` remains at 100% of the measured keys. +- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=ru` renders the dialogs with Russian + labels, without missing glyphs or clipped text. diff --git a/docs/localization.md b/docs/localization.md index 36e09f8a55..5a1355057c 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -28,6 +28,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | | Srpski | `sr` | one / few / many (teens use many) | +| हिन्दी | `hi` | one / other | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is diff --git a/docs/ora.md b/docs/ora.md index 2bf4f5112d..61125f060b 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -61,7 +61,7 @@ Unit tests (`crates/io/src/ora/tests.rs`) round-trip layers, groups, pass-throug opacity, visibility, locks and every blend mode at 8 and 16 bit, check masks, rendered layer kinds, the archive layout and malformed input. Synthetic 8/16-bit pixel fixtures check modern and legacy group-isolation defaults, explicit isolation and pass-through. -`crates/automation/tests/openraster.rs` saves, +`crates/automation/tests/it/openraster.rs` saves, reopens, edits and saves in place through the agent surface, and checks that a document with an adjustment layer never overwrites its `.ora`; the CLI test converts both ways. @@ -71,11 +71,11 @@ Real-file checks use Krita-authored oracles from `mergedimage.png`: ```sh -KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test it -- krita_corpus:: --ignored --nocapture ``` 2026-10-09, Krita 5.2.9: 32 of 35 `.ora` files render within 3/255 of Krita's merged image; the -other three differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's +other three differ for known reasons listed in `crates/io/tests/it/krita_corpus.rs` (Krita's tie-break in Darker and Lighter Color, and Hard Mix at a channel sum of exactly 1). In the other direction, Krita opened every PhotoCraft re-export of the first 34 of those files and rendered 33 of them identically to its original; the exception is Photoshop's Soft Light, which is written diff --git a/docs/pdn.md b/docs/pdn.md index dea41eb143..1a4e10940f 100644 --- a/docs/pdn.md +++ b/docs/pdn.md @@ -32,8 +32,8 @@ padding, reversed chunks, old/new mode records, native saves, cancellation and m The GPU parity tests include the additional modes. Local real-file checks are opt-in: ```sh -PDN_TEST_DIR=/path/to/pdn-and-png-exports cargo test -p photocraft-io --test pdn_corpus -- --ignored --nocapture -PDN_TEST_FILE=/path/to/layered.pdn cargo test -p photocraft-engine --test pdn -- --ignored +PDN_TEST_DIR=/path/to/pdn-and-png-exports cargo test -p photocraft-io --test it -- pdn_corpus:: --ignored --nocapture +PDN_TEST_FILE=/path/to/layered.pdn cargo test -p photocraft-engine --test it -- pdn:: --ignored ``` Each PDN in `PDN_TEST_DIR` needs a corresponding `.png` export with the same basename. The diff --git a/docs/plugins.md b/docs/plugins.md index e993a697f6..11139151c1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -84,6 +84,7 @@ exports. All integers are `i32` unless noted; pointers are offsets into the modu "description": "What it does.", "params": { "amount": {"type": "number", "min": 0, "max": 100, "default": 50}, + "shift": {"type": "number", "min": 0, "max": 40, "default": 4, "unit": "px"}, "steps": {"type": "int", "min": 1, "max": 16, "default": 4}, "mono": {"type": "bool", "default": false}, "mode": {"type": "choice", "options": ["soft", "hard"], "default": "soft"} @@ -102,6 +103,15 @@ exports. All integers are `i32` unless noted; pointers are offsets into the modu - `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, not `id`/`layer`. The host validates the user's values before calling: missing values take the default, numbers are clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. +- `unit` (optional, on `number` and `int` params): `"px"` marks a length in document pixels (a + radius, distance, shift or size); `"none"`, the default, is everything else (levels, factors, + counts, percentages). Any other value (an unknown string, a number…) is treated as `"none"`, as + when the field is absent, so older manifests keep installing. A filter dialog's live preview on a + large document runs on a reduced copy (Preferences › Performance › Low Resolution Previews), and + the host divides each `px` param by that reduction (ints rounded, kept within `min..max`) so the + preview shows the same lengths OK applies; when a `px` param with a positive `min` would drop below + it, the preview runs less reduced instead. Unitless params reach the preview unchanged, whatever + their name. OK, actions and smart filters always get the values as given. - `overlap` (0–256): pixels of context the filter needs around each output pixel (a blur radius). The host pads each band by `overlap` on every side, repeating the edge pixels of the canvas (or of off-canvas layer content), and keeps only the inner rectangle of the result. diff --git a/docs/printing.md b/docs/printing.md index 399944d57d..7b5d2a93fe 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -5,6 +5,22 @@ position and scale, crop and registration marks, labels, colour handling) and ha to a printer. The rendering is `print_cmds.rs` in `photocraft-engine`, shared by the dialog, the batch commands and automation (`file.print`, `file.printOneCopy`); this page is about the hand-off. +## Positioning the preview + +Drag the visible image on the paper to change Top and Left. Starting a drag switches Center +off using the currently resolved layout, including Scale to Fit Media, so the image stays at +the grab point. Top/Left stay visible but disabled while Center is on, keeping the dialog and +paper stationary when a drag enables them. Position fields accept negative values in inches, centimetres or millimetres; +the stored command parameters are inches from the sheet's top-left. Manual positions are +bounded to ±10000 inches. Height, width and paper dimensions retain their positive fields. + +The preview converts egui point displacement to PDF points using its paper display scale. +No extra Retina pixel factor is needed: painting and pointer input both use egui points. +Emulsion Down reverses the horizontal position change to match the mirrored output. The image +is clipped to the paper; any visible portion remains a drag target. A completely off-page image +can be recovered with Top/Left or Center. These controls affect print parameters only, and +the preview and generated PDF continue to share `print_cmds::layout`. + ## Routes to the printer | Route | When | What the user sees | diff --git a/docs/roadmap.md b/docs/roadmap.md index bca91616c7..85c355bc5f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,7 +31,7 @@ that blocks the workflow, would make PhotoCraft pre-alpha. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---:| -| Photo retouch: open JPEG/raw, heal/clone/remove, adjustment layers, export JPEG, save and reopen | yes | Spot Healing, Healing, Clone, Patch, Remove tools; 16 adjustment layers; raw for DNG/CR2/NEF/ARW/RAF; JPEG export; MCP workflow tests (`automation/tests/agent_tasks.rs`). Quality complaints (#1092, #2104) and no CR3 don't stop the workflow | 0 | +| Photo retouch: open JPEG/raw, heal/clone/remove, adjustment layers, export JPEG, save and reopen | yes | Spot Healing, Healing, Clone, Patch, Remove tools; 16 adjustment layers; raw for DNG/CR2/NEF/ARW/RAF; JPEG export; MCP workflow tests (`automation/tests/it/agent_tasks.rs`). Quality complaints (#1092, #2104) and no CR3 don't stop the workflow | 0 | | Composite: layers, masks, selections, blend modes, smart objects; save as PSD and `.pcraft` and reopen | yes | PSD round trip ≥ 169/170, 308/309, 258/256 corpus files; `.pcraft` atomic save and crash recovery. Multi-layer Smart Object conversion (#2604) and mask targeting (#2458) are partial but have workarounds | 0 | | Cut-out: select a subject, refine the edge, mask, export PNG with transparency | partial (not blocking) | Object/Quick Selection, Select Subject (classical), Refine Edge dialog, layer masks, PNG export. Select and Mask is a dialog, not the workspace; no Refine Hair (#2671) | 0 (depth: 80–140 h, G7) | | Design: type, shapes, layer styles, artboards; export PNG/JPEG; save and reopen | partial (not blocking) | Point/paragraph type, shapes with stroke controls, all 10 layer effects, artboards, Export As. One macOS report of a 60 s freeze on choosing the Type tool (#2518, not reproduced); no type on a path | 0 (fix #2518: 2–6 h) | @@ -57,7 +57,7 @@ real work is ~45%, inside the alpha band (~40–75%), but close to its floor. | M8 PSD v2 | 🟡 | adjustments (incl. Selective Color, Color Lookup), fills, effects, patterns, text, shapes, smart objects, alpha channels; oracle floors 146/170 (io), 236/309 (psd-tools), 133/256 (Photoshop-authored) | | M9 Text, vector, styles | 🟡 | type engine + Warp Text, shapes / pen / paths, all 10 effects on CPU and GPU (parity ≤1/255, 30/31 corpus effect files on the GPU) | | M10 Smart features | 🟡 | classical Select Subject / Object, content-aware fill and scale, healing, auto-align / auto-blend; ML backend not started | -| M11 Automation + formats | 🟡 | MCP (headless + live bridge), batch, Image Processor, prefs over MCP; DoD test passes (10 agent tasks over MCP, `automation/tests/agent_tasks.rs`); since 2026-10-07 the CLI rejects unknown flags and answers ` --help`, batch runs report same-name outputs instead of overwriting them, MCP `command_batch` steps can start background jobs, and headless saves never flatten over the opened file; JP2 / DICOM / DPX / C2PA pending | +| M11 Automation + formats | 🟡 | MCP (headless + live bridge), batch, Image Processor, prefs over MCP; DoD test passes (10 agent tasks over MCP, `automation/tests/it/agent_tasks.rs`); since 2026-10-07 the CLI rejects unknown flags and answers ` --help`, batch runs report same-name outputs instead of overwriting them, MCP `command_batch` steps can start background jobs, and headless saves never flatten over the opened file; JP2 / DICOM / DPX / C2PA pending | | M12 Pro parity | 🟡 | CMYK / Lab / Indexed / Bitmap / Duotone, ICC + soft proofing, channels + Quick Mask, smart-object stack modes, artboards, layer comps; print, HDR, timeline pending; Photomerge is implemented, with real-world quality validation still open | Releases: 0.1.0 (2026-10-02), 0.2.0 (2026-10-05, first signed and notarized), 0.3.0 (10-07), diff --git a/docs/scorecard.md b/docs/scorecard.md index 42ae824b4d..6e84b9847e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -1,20 +1,20 @@ # PhotoCraft scorecard -Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, `crates/io/tests/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers here supersede estimates elsewhere. +Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, `crates/io/tests/it/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers here supersede estimates elsewhere. ## Summary | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 170 (target 0) | -| [Type](#type) | 4 | 3 | 2 | | +| [Performance](#performance) | 3 | 22 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | +| [File compatibility](#file-compatibility) | 9 | 13 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 190/256, round trip ≥ 258/256 | +| [Tools](#tools) | 36 | 8 | 0 | | +| [Workspace and UI](#workspace-and-ui) | 14 | 6 | 3 | settings that do nothing: 21 of 176 (target 0) | +| [Type](#type) | 5 | 4 | 0 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | -| [Open bugs](#open-bugs) | 29 | 2 | 1 | | +| [Open bugs](#open-bugs) | 31 | 1 | 0 | | ## Performance @@ -43,6 +43,8 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | P17 | Move a layer on a 32-bit document with 20 layers | ≤ 100 ms p95 | 386 | 681 | 681 | 1.2 GB | 1.6 GB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | | P18 | Open a 20 MP layered PSD: first pixels (today: fully opened) | ≤ 1000 ms p95 | 1753 | 2794 | 2794 | 2.2 GB | 1.9 GB | over budget (target not met yet) | [#210](https://github.com/storytold/photocraft/issues/210) | | P19 | Open a 20 MP layered PSD: editable | ≤ 3000 ms p95 | 1753 | 2794 | 2794 | 2.2 GB | 1.9 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) | +| P20 | Open a 2 GB PSD: editable | opens with progress, editable, never blank | – | – | – | – | – | not run | [#210](https://github.com/storytold/photocraft/issues/210) | +| P63 | Open a 4–5 GB PSB: editable | opens with progress, editable, never blank | – | – | – | – | – | not run | [#210](https://github.com/storytold/photocraft/issues/210) | | P21 | Content-Aware Fill on 10 MP | ≤ 1500 ms p95 | 218 | 256 | 256 | 1.8 GB | 1.8 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) | | P22 | Spot Healing stroke on 10 MP | ≤ 500 ms p95 | 157 | 247 | 247 | 1.8 GB | 1.9 GB | pass | [#210](https://github.com/storytold/photocraft/issues/210) | | P24 | Import 500 brushes (longest UI-thread step) | ≤ 100 ms max | – | – | – | – | – | not run | [#210](https://github.com/storytold/photocraft/issues/210) | @@ -84,25 +86,24 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | Id | Scenario | Target | Why | Issue | |---|---|---|---|---| | P15 | Zoom / pan frame time on a 15000×10000 16-bit document | ≤ 16 ms per frame | no headless canvas frame harness yet (zoom and pan sample the GPU texture in the egui paint callback) | [#209](https://github.com/storytold/photocraft/issues/209) | -| P20 | Open a 2 GB PSD / 4–5 GB PSB | opens with progress, editable, never blank | opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216) | [#210](https://github.com/storytold/photocraft/issues/210) | | P23 | Warp / Puppet / Perspective preview at 4K | ≤ 16 ms per frame, commit in the background | the previews run inside the UI tools; no headless frame harness, no background commit | [#210](https://github.com/storytold/photocraft/issues/210) | | P34 | Brush preview memory, small dab on an 8K viewport | ≤ 2 MiB | the live brush preview buffers are UI-side; no headless counter yet | [#211](https://github.com/storytold/photocraft/issues/211) | | P35 | Marquee drag on 24 MP with Alt/Shift | ≤ 17 ms p95 | Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side | [#211](https://github.com/storytold/photocraft/issues/211) | ## File compatibility -Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them. +Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them. | Corpus | Directory | Files | Oracle pass floor | Round-trip floor | |---|---|---:|---:|---:| | io corpus | `corpus/psd` | 170 | 148 | 169 | | psd-tools corpus | `corpus/psd-tools` | 309 | 249 | 308 | -| photoshop oracles | `corpus/photoshop` | 256 | 188 | 258 | +| photoshop oracles | `corpus/photoshop` | 256 | 190 | 258 | | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | -| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | +| FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/it/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | +| FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 41 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/it/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | | FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | @@ -113,21 +114,21 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | | FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp | | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | -| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | +| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/it/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | -| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | +| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | -| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | -| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | +| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | done | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks | +| FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | | FILE-216-1 | Every corpus fetched and enforced in CI, cached by pin | done | [#216](https://github.com/storytold/photocraft/issues/216) | ci.yml has a corpus job (name: corpus tests) on every PR and on main: the corpora are pinned in xtask/src/corpus_pins.rs, sha256-verified after both a cache restore and a download, and a missing corpus fails the tests. The cache key follows the pins, so it is cached by pin as this row asks (#190) | -| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | partial | [#216](https://github.com/storytold/photocraft/issues/216) | 4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI | -| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | +| FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | done | [#216](https://github.com/storytold/photocraft/issues/216) | Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/it/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P63). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk; ~12 GB of memory for the PSD test, ~25 GB for the PSB test) (#5107) | +| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/it/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | | FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import | -| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | -| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 20 MCP tasks | +| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/it/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/it/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | +| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/it/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks | ## Tools @@ -136,10 +137,10 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | -| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | +| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, #4385); a selection over 256x256 shows a coarse result solved scaled down first, then the command's exact result as a second job (#6680; 24 MP document, release: 600x400 coarse 0.26 s then exact 0.8 s, 1500x1000 0.38 s then 4.1 s, 3000x2000 0.69 s then 9.4 s; was no preview above 256x256). Tests: content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths, content_aware_move_coarse_preview_handles_transform_extend_and_limits, a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit, a_large_selection_shows_a_coarse_result_then_the_exact_one | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | -| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | +| TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Not yet live: Mixer Brush (in PR #3627) | | TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | @@ -151,7 +152,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | -| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | +| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas_tool_menu.rs TRANSFORM_MENU: modes, Content-Aware Scale / Puppet Warp greyed, Rotate 180 / 90 CW / 90 CCW and Flip turn the open box (transform_tool::intercept, #2832) | | TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | done | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs build_mesh: traced outline of the opaque region grown by Expansion, triangulated with a Density-spaced lattice (conforming Delaunay); separate shapes get separate meshes (#3105) | | TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | done | [#214](https://github.com/storytold/photocraft/issues/214) | Edit > Content-Aware Scale opens a scale-only Free Transform box (cas_transform.rs) with Amount, Protect and Protect Skin Tones in the options bar; edit.contentAwareScale carves a 384 px copy in the background for the live preview, and Enter runs it with the box's size and x/y as one step (#3096) | @@ -159,8 +160,8 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | -| TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | done | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15) | +| TOOL-214-13 | Mask Properties: Density and Feather controls | done | [#214](https://github.com/storytold/photocraft/issues/214) | Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs:34 pixel mask, :48 vector mask, :52 the sliders; mask_props_ui_tests.rs:39 pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, :81 mask_slider_drags_preview_live_and_undo_as_separate_gestures, :147 vector_mask_properties_edit_density_and_feather) | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | @@ -180,13 +181,13 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 170** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 21 of 176** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.gpu_adapter`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `performance.cache_levels`, `performance.gpu_adapter`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options | +| UI-217-1 | Tool options, last Export As / Save for Web / Fill settings and font recents persist | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list) | | UI-217-2 | Window size and dock width remembered across restarts | done | [#217](https://github.com/storytold/photocraft/issues/217) | eframe persistence (ui.ron): persist_window for the window, egui memory for the dock panel width; window_and_panel_geometry_survive_a_restart asserts the round trip (apps/photocraft/src/main.rs; b07a2e5b, #587) | | UI-217-3 | File > Open starts in the last-used folder | done | [#217](https://github.com/storytold/photocraft/issues/217) | file_dialog.rs last_used_dir: open dialogs ask with initial_dir (the most recent file's folder) and apps services.rs passes it to rfd set_directory; open_starts_in_the_last_used_folder + last_used_dir_takes_the_most_recent_parent | | UI-217-4 | Preferences all take effect; recent files persist and honour recent_file_count (#204) | missing | [#217](https://github.com/storytold/photocraft/issues/217) | See the prefs audit and BUG-204-* | @@ -196,14 +197,14 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); over committed type the Type tool opens the type layer menu (#7324); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); the canvas dot pattern toggle is interface.showCanvasDots (#2264, global rather than per theme); interface.showHomeRecentFiles hides the Home screen recents (#4382) | -| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | +| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | done | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); the light themes outline the slider knob with Tokens::knob_outline (dim text, at least 3:1 against dock, card and chrome; #7927) | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) | -| UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | +| UI-217-16 | Hue/Saturation: Saturation slider gets a hue-following colour track, Lightness a black-grey-white track | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue, recolouring as the Hue slider moves, and a fixed black -> grey -> white (#6539); saturation_track_follows_the_hue, lightness_track_is_black_grey_white_whatever_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | -| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | missing | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs:1107-1125 no Alt handling | +| UI-217-18 | Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped | partial | [#217](https://github.com/storytold/photocraft/issues/217) | Alt+wheel zooms around the pointer, one step per notch applied the frame it arrives (wheel_nav.rs:94; input_tests.rs:443 alt_scroll_zooms_in_steps_around_the_pointer, wheel_nav.rs:448 alt_scroll_zooms_ten_percent_per_notch), but the step is x1.1 (10 %), not the ~5 % this row asks for: #1718 changed it from x1.05 after measuring Photoshop, so the row's target is in question. Status bar zoom field and Fit button fit inside the bar in every theme (status_bar_tests.rs:39 status_bar_controls_sit_inside_the_bar_with_even_margins, #2823); the Navigator zoom controls are not checked | | UI-217-19 | Shortcut hints rendered per platform; Reveal label per OS | done | [#217](https://github.com/storytold/photocraft/issues/217) | hints and tooltips go through shortcuts::pretty (6266b7a9, #262; pretty_uses_the_platform_notation); menus::reveal_label names Reveal in Finder per OS (Show in Explorer / Show in Folder) in the Smart Objects menu and the tab menu; reveal_label_names_the_platform_file_manager + smart_objects_reveal_item_names_the_platform_file_manager | | UI-217-20 | 'Show rulers in new documents' preference; guides stored as f64 | done | [#217](https://github.com/storytold/photocraft/issues/217) | View › Rulers is remembered in unitsAndRulers.showRulers and applied at launch and on change (#2918; rulers_open_as_last_set_after_a_restart); doc Guides are Vec through New Guide, .pcraft and PSD 1032 (guides_keep_sub_pixel_positions_through_pcraft_and_psd) | | UI-217-21 | One Preset Manager for every preset kind with folders, search, reorder, load/save sets | partial | [#217](https://github.com/storytold/photocraft/issues/217) | edit_menu_cmds.rs:714 covers brushes, custom shapes, patterns with reorder | @@ -214,13 +215,13 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | -| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text) | +| TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order), and PSD doesn't store the direction (crates/text psd.rs neither reads nor writes it, so it reopens as Auto) | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | -| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | -| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 | +| TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/it/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | +| TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | done | [#218](https://github.com/storytold/photocraft/issues/218) | Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs:503 warp_defaults; warp_text_dialog_tests.rs:80 warp_text_opens_with_the_type_layers_current_warp, :99 warp_text_opens_at_none_for_a_layer_without_warp, :108 remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (:138 warp_text_dialog_previews_the_warp_without_editing_the_document, :160 cancelling_warp_text_leaves_the_document_and_history_untouched, :179 confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs:2150 warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy) | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) | | TYPE-218-9 | Distort/Perspective/Warp on live type as in Photoshop (was: offer 'Rasterize and continue?'; Photoshop doesn't prompt) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Photoshop 2026 prompts for none of these: Edit › Transform › Warp on live type stays enabled with no rasterize prompt and offers only the warp styles (the mesh can't be dragged), so the type stays editable; Distort, Perspective and the split warps are greyed. PhotoCraft does the same: Warp on type commits the layer's Warp Text in one step (transform_tool.rs enter_type_warp, warp_cmds.rs warp_type_layer, #3004; warp_on_type_takes_a_style_and_keeps_the_type_editable), Distort/Perspective greyed (enable_rules.rs, #2701) | @@ -240,7 +241,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | -| AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | +| AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/it/agent_tasks.rs runs 10 workflows over MCP | ## Reliability @@ -283,28 +284,28 @@ Never-crash attribute on 30 of 30 crate roots. | BUG-203-5 | On any failure the original file is untouched and the temp file removed | done | [#203](https://github.com/storytold/photocraft/issues/203) | atomic.rs removes the temp file on every failure; tests assert the original bytes survive write and rename failures | | BUG-203-6 | Failure-injection tests: mid-write, rename and read-only dir errors keep original bytes | done | [#203](https://github.com/storytold/photocraft/issues/203) | format/src/atomic/tests.rs: Faulty AtomicFs injects mid-write StorageFull, rename failure and a read-only dir; locked-target retries, symlink and file-mode preservation covered too | | BUG-204-1 | Interface prefs (UI scale, font size, channels in colour, sliders, tooltips) wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | prefs_ui.rs tick reads ui_scale (sync_display_scale), ui_font_size (theme::set_ui_font_size) and show_tooltips (sync_tooltips); show_channels_in_color is wired in channel_thumbs and the canvas channel view (#2385); dynamic_color_sliders is in prefs::HIDDEN_UNTIL_IMPLEMENTED | -| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED | +| BUG-204-2 | workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom gates discrete canvas zoom transitions and continuous held Zoom tool presses (zoom_hold.rs, #6210), and is exposed in General preferences | | BUG-204-3 | RAW prefs and ask_before_saving_layered_tiff read, or hidden | done | [#204](https://github.com/storytold/photocraft/issues/204) | ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED | | BUG-204-4 | Recent files persisted through prefs across restarts | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/ui-egui/src/lib.rs::push_recent stores the list in preferences; prefs_ui::load restores it and recent_files_survive_a_restart_and_honour_the_count verifies reload | | BUG-204-5 | Recent-files list honours recent_file_count (default 20) | done | [#204](https://github.com/storytold/photocraft/issues/204) | lib.rs push_recent caps the list with recent_cap (recent_file_count); menus.rs covers default and custom limits | -| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both | +| BUG-204-6 | Test: every Prefs field is read somewhere or on a hidden allowlist | done | [#204](https://github.com/storytold/photocraft/issues/204) | crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both | | BUG-205-1 | Free Transform with a selection moves only the selected part of a linked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_mask_with_a_selection_moves_only_the_selected_region (transform_cmds.rs; 5a5facc, #242) | | BUG-205-2 | Live transform preview draws the layer with its mask applied | done | [#205](https://github.com/storytold/photocraft/issues/205) | transform_tool.rs preview_mask + preview_applies_the_linked_mask (5a5facc, #242) | | BUG-205-3 | edit.transform honours target mask: transforms only a targeted, unlinked mask | done | [#205](https://github.com/storytold/photocraft/issues/205) | targeted_unlinked_mask_transforms_alone and targeted_linked_mask_moves_with_its_layer (transform_cmds.rs) plus the through-the-box tests (transform_tool.rs; 5a5facc, #242) | | BUG-205-4 | Test: a linked raster mask with no selection moves with the pixels | done | [#205](https://github.com/storytold/photocraft/issues/205) | linked_raster_mask_moves_with_the_pixels (transform_cmds.rs): a reveal-all mask with a hidden half, linked, no selection; asserts the hidden half travels with the pixels and nothing hidden is left behind | | BUG-205-5 | Tests: linked mask + selection stays aligned; unlinked stays; targeted mask only | done | [#205](https://github.com/storytold/photocraft/issues/205) | All three in transform_cmds.rs: linked_mask_with_a_selection_moves_only_the_selected_region checks the mask value under each moved pixel, unlinked_mask_stays_put covers it with and without a selection, targeted_unlinked_mask_transforms_alone moves the mask while the layer bounds stay | | BUG-206-1 | CharStyle models per-character manual kerning separately from the auto-kern mode | done | [#206](https://github.com/storytold/photocraft/issues/206) | doc/src/text.rs CharStyle carries both: kerning: Kerning for the automatic mode (Metrics/Optical/Off) and kern: f32 for manual kerning in 1/1000 em between this character and the next, added on top of the pair's automatic kerning | -| BUG-206-2 | PSD import keeps Optical and manual Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | io/tests/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348) | -| BUG-206-3 | PSD export writes the real AutoKerning mode and Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | manual_and_off_kerning_round_trip_through_psd (io/tests/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so | +| BUG-206-2 | PSD import keeps Optical and manual Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | io/tests/it/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348) | +| BUG-206-3 | PSD export writes the real AutoKerning mode and Kerning values | done | [#206](https://github.com/storytold/photocraft/issues/206) | manual_and_off_kerning_round_trip_through_psd (io/tests/it/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so | | BUG-206-4 | Alt+Left/Right at a collapsed caret adjusts pair kerning; Character panel field | done | [#206](https://github.com/storytold/photocraft/issues/206) | type_tool.rs: Alt+Left/Right at a collapsed caret calls kern_pair (20 per step, 100 with Cmd); the Character panel field takes Metrics, Optical or a manual value (kerning_label / parse_kerning). Covered by type_tool_alt_arrows_kern_the_pair and kerning_field_values (input_tests.rs) | -| BUG-206-5 | Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus | partial | [#206](https://github.com/storytold/photocraft/issues/206) | Round trips covered by io/tests/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values | +| BUG-206-5 | Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus | partial | [#206](https://github.com/storytold/photocraft/issues/206) | Round trips covered by io/tests/it/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values | | BUG-207-1 | Retouch tools respect the paint target (mask, alpha channel, Quick Mask) | done | [#207](https://github.com/storytold/photocraft/issues/207) | run_stroke paints channel_cmds::target_surface; retouch_strokes_paint_the_targeted_mask (retouch_ui.rs) and engine retouch_cmds/tests.rs (e2444ce, #234) | | BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | done | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_ui.rs passes sampleAllLayers to paint.blur/sharpen/smudge; sample_all_layers_reaches_the_command (e2444ce, #234) | | BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | done | [#207](https://github.com/storytold/photocraft/issues/207) | paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_dark_fringe (e2444ce, #234) | | BUG-207-4 | edit.transform transforms a targeted alpha channel | done | [#207](https://github.com/storytold/photocraft/issues/207) | edit.transform follows a targeted alpha channel or Quick Mask (explicit target since #242, Channels panel target with no target/layer param since #2835); Edit > Transform > Rotate/Flip are routed to the target too; transforms_follow_the_targeted_alpha_channel | -| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Only smudge_drags_colour_along_the_stroke (retouch.rs:407) | +| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | done | [#207](https://github.com/storytold/photocraft/issues/207) | Every retouch tool (Clone Stamp, Pattern Stamp, Healing, Spot Healing, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush, Remove) is tested to paint only a targeted mask and only a targeted alpha channel (engine retouch_cmds/tests.rs:786 retouch_tools_paint_only_the_targeted_mask, :805 retouch_tools_paint_only_the_targeted_alpha_channel; Healing, Spot Healing, Sponge and Remove are checked to leave the rest untouched, not to change the target, since they may leave grey noise as it was); the other eight follow the Channels panel target (:822 retouch_tools_follow_the_channels_panel_target); Quick Mask and bad targets: :840 retouch_tools_paint_the_quick_mask, :853 retouch_targets_fail_gracefully. Sample All Layers changes the result over two layers (:879 sample_all_layers_blur_and_sharpen_work_on_an_empty_layer, :907 sample_all_layers_smudge_drags_colour_from_below, :923 sample_all_layers_spot_healing_heals_onto_an_empty_layer, :313 clone_stamp_sample_all_layers_onto_empty_layer_and_opacity; ui retouch_ui.rs:818 sample_all_layers_reaches_the_command); no smudge fringe (:942 smudge_across_a_transparent_edge_has_no_dark_fringe). Brush/Eraser/Gradient/Bucket targets: engine tests.rs:1263 painting_can_target_the_layer_mask, channel_cmds/tests.rs:301 painting_filtering_and_adjusting_into_an_alpha_channel. Not covered: Pencil and Mixer Brush into a mask or channel; the Channels panel target for Healing, Spot Healing, Sponge and Remove | | BUG-208-1 | Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners applies Shift via Drag::marquee_mods; marquee_tests.rs::shift_during_the_drag_draws_a_square_and_the_readout_follows covers the drag | | BUG-208-2 | Alt while dragging a marquee draws from the centre | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners centres Alt drags; marquee_tests.rs::alt_draws_from_the_centre_and_shift_alt_a_centred_circle verifies rectangles and ellipses | | BUG-208-3 | W x H px readout beside the cursor while dragging a marquee | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_readout draws the readout; marquee_tests.rs asserts dimensions while dragging and its removal after release | -| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | partial | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195) | +| BUG-208-4 | Add/subtract/intersect from modifiers pressed before the drag, kept by a test | done | [#208](https://github.com/storytold/photocraft/issues/208) | Mods captured at press. Marquee: Shift at the press adds and Alt subtracts, with no square or centring (ui-egui marquee_tests.rs:361 modifiers_held_before_the_drag_pick_the_mode_not_the_shape), and Shift+Alt held at the press intersects (marquee_tests.rs:410 shift_alt_before_the_drag_intersects_the_selection). Lasso: Alt, Shift and Shift+Alt at the start subtract, add and intersect (lasso_ui/tests.rs:96 initial_alt_still_subtracts_and_initial_shift_still_adds). Polygonal Lasso: Shift or Alt at the first click keeps the selection in view, and a Shift polygon adds to it (polygon_lasso_tests.rs:209 modifiers_at_the_first_click_keep_and_combine_the_selection); no test commits an Alt (subtract) polygon | | BUG-208-5 | Kittest: Shift drag gives a square, Alt drag is centred, readout matches the size | done | [#208](https://github.com/storytold/photocraft/issues/208) | crates/ui-egui/src/marquee_tests.rs covers Shift, Alt, selection bounds, W/H readout and drag completion | diff --git a/docs/shape-strokes.md b/docs/shape-strokes.md index 835eff76c2..61305c07b8 100644 --- a/docs/shape-strokes.md +++ b/docs/shape-strokes.md @@ -1,6 +1,6 @@ # Shape stroke options -Updated 2026-10-11: direct editing, compact layout, and clickable colour/style controls. +Updated 2026-10-15: retain stroke paint and options when width reaches zero (#6191). PhotoCraft's Shape tool options bar has a Stroke Options button beside the existing width and colour controls. It sets defaults for new Rectangle, Ellipse, Triangle, Polygon, Line @@ -15,6 +15,11 @@ colour or style deliberately enables a 3 px stroke when no stroke is present. Th remains available with no stroke, but opening or dismissing it alone does not enable one. Properties edits the selected shape; the options bar continues to set defaults for new shapes. +Setting an existing shape's stroke width to 0 px hides the stroke while retaining its paint, +opacity and geometry options. Raising the width restores the same style, including after a +native save and reopen. No Color still removes the stroke; deliberately choosing a colour or +style enables a zero-width stroke at 3 px. Width edits remain one undo step per numeric drag. + The editor offers Solid, Dashed (4 widths on, 2 off), Dotted (zero-length dashes with round caps, spaced every 2 widths), and custom repeating dash/gap sequences. Dash lengths and offsets are multiples of the stroke width; width itself is in document pixels. Odd-length diff --git a/docs/smart-filters.md b/docs/smart-filters.md new file mode 100644 index 0000000000..278c989966 --- /dev/null +++ b/docs/smart-filters.md @@ -0,0 +1,55 @@ +# Smart Filters in the Layers panel + +A Smart Object with filters has a rings indicator and a disclosure arrow beside +its name. The arrow hides or shows both its layer effects and Smart Filters; it +does not disable them or change the document. Alt-click applies that view choice +to all layers with effects or Smart Filters. + +The Smart Filters header shows the shared filter mask and the stack's visibility +control. Filters appear newest first, while the engine evaluates them from bottom +to top. Each filter has its own visibility control. + +Double-click a filter's name to edit its saved parameters in its existing editor. +OK updates that filter; it does not append a second copy. Parameter-free filters +such as Invert have no settings dialog. Camera Raw retains its dedicated editor. + +Double-click the sliders icon at the right of a filter to open **Blending Options**. +Mode and Opacity belong to that filter, independently of the layer and other +filters. Opacity is shown as a percentage (1% means 0.01). Preview changes the +temporary canvas composite and the dialog's image viewport. Zoom controls and +scrolling affect only that viewport. The viewport uses a bounded image thumbnail; +it is a navigation preview, not a full-resolution detail inspection. + +OK applies one undoable edit. Cancel or Escape leaves the document untouched. +Editors capture their document snapshot, revision, layer and filter. Reopening +the same saved document also invalidates a retained dialog. A document switch, +Undo/Redo, filter deletion/reorder or pending document job requires reopening the +dialog rather than applying settings to a changed target. + +Ctrl+I / Image > Adjustments > Invert on Smart Object content uses the existing +engine adjustment command to add an Invert Smart Filter, preserving the source. +When a layer mask or channel is targeted, its existing adjustment semantics apply. + +## Storage and current limits + +`.pcraft` stores the complete Smart Filter stack, parameters, shared mask, +visibility and per-filter blending. PSD only supports the filters mapped by +`crates/io/src/smart_map.rs`: Invert and Smart Sharpen currently lack live PSD +mappings. PSD export can warn and omit unmapped live filters. This interface +change does not extend the PSD mapping table or unknown-descriptor support. + +Preview reuses the native filter worker and reduced document pipeline. Some +filters require full-resolution previews. Committing edits and toggling filter +eyes retain the cached stages and background jobs already supplied by the engine +(PR #2739). This change does not add a new GPU filter implementation or establish +large-image performance budgets. + +Filter opacity interpolates premultiplied input and filtered results, so +alpha-preserving filters retain translucent coverage at partial strength. This +also applies to Normal mode when a filter changes coverage. Non-Normal modes +use source-over compositing when the filtered surface changes alpha, preserving +input-only and filtered-only pixels around blur edges. The choice applies to +the whole filtered surface. This is scoped to Smart Filters; global layer +compositing is unchanged. Dissolve +currently follows the inherited Smart Filter color-blending path without +per-pixel coverage sampling; exact Dissolve parity remains a separate task. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3eca518c27..c6af89fada 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -170,16 +170,16 @@ parity for the area and are already contained in the dimension rows above. | Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | | Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | -| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | +| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); no live stroke preview for the Mixer Brush yet (TOOL-213-7) | | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | -| Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | +| Type | 40% | 120–200 | Engine works; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; Type tool freeze on macOS (#2518) | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); compound layer properties export as separate Set events with rename last (#8304, 2026-10-21; independent Photoshop replay pending); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | -| Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | +| Print, export, Save for Web | 50% | 60–100 | JPEG Options without Matte, progressive Scans or size preview (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | | Workspace, panels, preferences | 40% | 150–250 | 46 of 149 prefs do nothing; no floating/OS-window panel groups; Libraries, Comments, Contextual Task Bar missing | | AI / generative | 10% | 300–600 | See the dimension row | @@ -286,7 +286,7 @@ just presence. | Transform, warp, puppet, perspective, liquify | ~88% | No Face-Aware Liquify (needs a landmark model) | | Automation: CLI, MCP, headless server, control channel | ~95% | Ahead of Photoshop here (see below) | | Image modes (RGB/Gray/CMYK/Lab/Indexed/Duotone/Multichannel) | ~85% | Multichannel display-only; no ink-plate model | -| Print / export / Save for Web / slices / Generator | ~85% | No Separations/bleed; droplets basic | +| Print / export / Save for Web / slices / Generator | ~85% | No Separations; Bleed only through `file.print` (no Print dialog field yet); droplets basic | | Computational photo (Photomerge, HDR, Lens, Camera Raw, Wide Angle) | ~70% | Classical implementations; generic lens profiles only | | Data-driven graphics (Variables, Data Sets) | ~0% | Not started (5 menu items) | | Video / Timeline / animation | ~0% | Not started (13 menu items) — a whole subsystem | @@ -438,13 +438,13 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s |---|---|---|---| | Menu wiring | 626/626 menu items dispatch a command (`parity-checklist.md`) | high but shallow | Says nothing about behaviour. | | PSD fidelity (rendering) | Corpus oracle 115/170 (68%): 30 differ, 26 have no usable reference, 1 import error. 2026-10-07: io corpus 146/170, psd-tools corpus 236/309 (was 229: Advanced Blending knockouts), Photoshop oracles 132/258 | medium | Push to 170/170 under way (effects/strokes, multi-instance effects, 16/32-bit and colour modes, references for skipped files). | -| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | +| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/it/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | | OpenRaster read/write | 2026-10-09: 34 Krita 5.2.9-authored `.ora` files (27 blend modes, groups, pass-through, offsets, opacity, visibility, masks, 16-bit, gray) render within 3/255 of Krita's merged image (30) or differ for known reasons (4: Krita's lighter/darker-colour tie-break, Hard Mix at exactly 1, a linear-light 16-bit document); Krita re-opens our exports and renders them identically to its originals (33/34; Soft Light is written as `svg:soft-light`) | medium (one authoring app) | MyPaint and GIMP files untested. Masks are applied to pixels and layer styles dropped on save (reported). See [OpenRaster](ora.md). | | Paint.NET import | 2026-10-09: 14/14 local PDN3 textures preserve editable layers exactly through `.pcraft` and render within 2.142/255 of full-size previews; a supplied 5.x document (1280×720, 11 layers) passes editing/history/native saves and matches its thumbnail within 0.418/255 mean; all 14 blend modes exercised synthetically | medium (limited corpus) | Import only; metadata omitted. More current-version files and full-size exports needed. See [PDN support](pdn.md). | -| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | +| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/it/corpus.rs` and `crates/engine/tests/it/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. The only tool still missing is Art History Brush: Frame landed (#2914), Red Eye is a live toolbar tool (`paint.redEye`, TOOL-213-17) and Perspective Crop landed (2026-10-10, #1043, `image.perspectiveCrop`) | low–medium | Patch has a live healing preview. Content-Aware Move is done (`TOOL-213-4`): Move/Extend, Structure/Color, Sample All Layers, Transform On Drop, and a live result preview for every selection the command takes (coarse first for large ones, then exact; 2026-10-16, #6680). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | @@ -553,7 +553,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). checked against Photoshop's output on every build. Their pass rate becomes the headline parity number. 4. **Tool coverage and workflow depth:** Pen variants (Direct Selection landed, #790; Add/Delete Anchor Point, #1044); - finish Content-Aware Move's Transform On Drop and live result preview. + Content-Aware Move's Transform On Drop and live result preview are done (2026-10-16). Pencil, Mixer Brush, Patch, Vertical Type, Pattern Stamp and Rotate View are already available on the toolbar. 5. **Complex-document performance** (#125/#128) and GPU tiling beyond the texture limit (#49). 6. **Recurring visual QA** (`cargo run -p photocraft-engine --example designer_psd`) and fast @@ -565,13 +565,20 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | #8304: compound layer properties export as separate Set events, rename last; independent Photoshop replay pending; percentages and estimates unchanged | +| 2026-10-19 | minor | JPEG Options dialog landed (FILE-215-19 partial: no Matte, progressive Scans or size preview); percentages unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | +| 2026-10-16 | minor | Content-Aware Move: live result preview for every selection the command takes (TOOL-213-4 done, #6680); percentages unchanged | +| 2026-10-15 | minor | Vector shapes: shape layers take Density and Feather (Properties › Masks), PSD and .pcraft round trip; TOOL-214-12 done (#5846); estimates unchanged | +| 2026-10-15 | minor | Print: Functions › Bleed (`file.print` / `file.printOneCopy` `bleed`, corner crop marks on the trim line, PDF and Windows paths); the Print dialog field is still missing; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | +| 2026-10-11 | minor | Type row evidence: manual/optical kerning and the live Warp Text preview are done (TYPE-218-6/7; variable-font axes TYPE-218-4 too); the 40% estimate is not re-measured | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | | 2026-10-12 | minor | Rows checked against `main` and the regenerated scorecard: menu count 631, Workspace checklist and preference count, transform context menu, Puppet Warp mesh, Select and Mask, anchor-point tools, Smart Object conversion, Stamp Visible, mask targeting, Free Transform commit/cancel, Vietnamese catalog, Windows ARM64; percentages, weights and hours not re-measured | +| 2026-10-11 | minor | Retouching evidence: Pattern Stamp and Background Eraser draw live (#3754), only the Mixer Brush is left of TOOL-213-7; percentages unchanged | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | diff --git a/docs/ui-design.md b/docs/ui-design.md index d0ee20f7bb..8ac01f8e01 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -61,6 +61,7 @@ curl -sfL -o assets/icons/.svg https://raw.githubusercontent.com/lucide-ic | Channels selection | `channels_panel.rs` | Load channel as selection in the footer follows the highlighted channel row. When the active layer mask is targeted for painting while the underlying channel view remains Composite, it loads that layer mask; otherwise it loads the selected composite, color, alpha or vector mask channel. | | Path thumbnails | `vector_ui.rs` | Ctrl/Cmd-click the thumbnail of a Work Path, saved path or layer path to load it as a selection; Shift adds, Alt subtracts, Shift+Alt intersects. Clicking the row name only selects the path. | | Levels / Curves | `tone.rs` | Histogram of the image *below* the adjustment. Curves: click to add a point, drag out to delete. Every change is a coalesced `layer.setAdjustment`, so one drag = one undo step and the canvas updates at full resolution on the GPU. | +| Radial Blur: Zoom | `filter_dialog/zoom_center.rs` | A ray diagram to the left of Center X/Y sets the centre by click or drag. Diagram, sliders and numeric fields share the same coordinates; arrow keys on the diagram step by 0.01. Ray lengths follow Amount, and the existing image preview follows every edit. OK applies the filter; Cancel discards the edits. | | Shape Stroke Options | `shape_stroke_ui.rs`, `vector_ui.rs` | Direct editing: valid changes apply immediately, with one numeric drag per undo step in Properties. Escape/outside click closes the popup and retains changes; a child dropdown keeps the parent open. The options bar sets new-shape defaults; Properties edits the selected shape. | Shape Stroke Options (updated 2026-10-11) sizes to its content and scrolls only when necessary. diff --git a/docs/ui-dialogs.md b/docs/ui-dialogs.md index 032edfde5d..8767f59714 100644 --- a/docs/ui-dialogs.md +++ b/docs/ui-dialogs.md @@ -50,6 +50,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor | `__stroke` | `stroke_ui.rs` | Edit › Stroke… | | `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) | | `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation | +| `__replaceSaved` | `replace_prompt.rs` | Replace prompt when a save's added or lowercased extension names an existing file | | `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N; ⌥⇧⌘N makes the layer without it) | | `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt | | `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation | @@ -101,6 +102,7 @@ actions with their marker already set. | `__stroke` | `stroke_ui` | | `__createShape` | `shape_dialog` | | `__deleteLayer` | `delete_layer_prompt` | +| `__replaceSaved` | `replace_prompt` | | `__newLayer` | `new_layer_ui` | | `__rasterize` | `rasterize_prompt` | | `__resetAllTools` | `tool_reset` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..6f1e690e3f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Hand tool ⌘ / ⌥ are Zoom In / Zoom Out while held, #6434; the wheel steps value fields, #5473; a dragged guide shows its X or Y beside the pointer, #7680; Type options bar "T" icon scrubs the font size, #6437; Move tool: dragged selected pixels take their own outline as the selection, #7538; artboard backgrounds remain below child Free Transform previews; ⌫/Delete removes the selected smart filter, #4751; Object Finder foreground/background discrimination (#6548); rounded triangle, polygon and star corners; shape Fill/Stroke popovers accept hex RGB values, #6176; a guide let go over either ruler is removed, #6286; Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; marching ants in device pixels, #7489; Animated Zoom: holding the Zoom tool zooms continuously, #6210; shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Where the active layer has no pixels under the pointer (Sample All Layers off), the status bar says so instead of showing nothing (#6549). Dominant enclosed objects are retained; regions covering most of the canvas boundary are treated as background (#6548). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | @@ -94,6 +94,8 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Arrow nudge of path points with Direct Selection | missing | no arrow handling in `direct_select.rs` | | ⌘-arrow nudges the layer from any tool | missing | #2474 | | ⌥-drag duplicates, ⇧ constrains to 45° | done | `move_mods.rs` | +| Dragging selected pixels shrinks the selection to them (the selection limited to where the layer has pixels, so moving again leaves no edge behind) as the drag starts, also with ⌥; the move box, snapping and Show Transform Controls use the moved pixels' bounds | done | #7538, `canvas.rs` `floating_outline` and `transform_controls_rect`, `snap_ui.rs` `follow_cut`, engine `float_cmds.rs` `CutParts::outline` | +| With several layers selected and a selection, the Move tool moves the layers whole and the selection with them (ants follow the drag; one Move step) | done | #7538, `move_ui.rs` `carries_selection`, engine `layer.translate` `selection` | | Auto-Select with hover outline, box-select layers, double-click fit | missing | TOOL-214-4 | | Auto-Select bounding box and snapping follow the picked layer | partial | follow the previous layer (#2666) | | Several layers selected (⇧-click adds, box-select): Show Transform Controls frames them all, with every selected shape's outline | partial: the shared frame has no handles, since Free Transform works on one layer (`canvas.rs` `transform_controls_layers`, `several_selected_layers_share_one_frame_without_handles`; `move_auto_select_tests.rs`) | | @@ -107,13 +109,14 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | ⇧ square/circle, ⌥ from centre, live W × H readout (marquee) | done | `marquee_tests.rs` | -| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | partial | works; only a badge test (BUG-208-4) | +| ⇧ add, ⌥ subtract, ⇧⌥ intersect from modifiers at press | done | tested for the marquee (add, subtract, ⇧⌥ intersect: `marquee_tests.rs`), Lasso (all three: `lasso_ui/tests.rs`) and Polygonal Lasso (`polygon_lasso_tests.rs`) (BUG-208-4) | | Space repositions the marquee while dragging | done | `hold_keys.rs` | | Polygonal Lasso ⇧ snaps to 45° | missing | #2352 | | Autoscroll at the window edge while selecting | missing | #2231 | | Single-pixel rows and columns kept on release | partial | #2391 | | Esc deselects / ⌘Z undoes the last selection | partial | #2674, #1432 | | Select › Modify live preview | missing | #1752 | +| Marching ants as Photoshop draws them: device pixels, every edge equally thick (1 px at 100 %, 2 px up to 200 %), black/white dashes 4× the thickness, slanted dash ends, same look for drag previews and committed selections | done | `ants.rs`, `tests/it/marching_ants_pixels.rs` (#7489) | ## Pen, paths and shapes @@ -125,7 +128,10 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | +| Shape Density and Feather (Properties › Masks: a shape's path is its vector mask) | done: Density and Feather sliders for a selected shape layer, one undo step per drag, through `layer.vectorMask.edit`; the fill shows beyond the path at 100 − density %, a feather is the vector mask's Gaussian (Photoshop's 10 px ramp within 1 level); fill and stroke stay editable, the stroke stays crisp; PSD mask parameters and .pcraft round trip (#5846) | TOOL-214-12 | +| On-canvas corner-radius handles on live shapes | done: every selected live rectangle, triangle, polygon and star shows a widget at each arc centre with Path Selection, the Rectangle, Triangle and Polygon tools and Move with Show Transform Controls; a drag rounds every corner of every selected shape to the same radius, ⌥ one corner of a rectangle (a triangle or polygon has one radius), with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | +| Shape options-bar Fill/Stroke hex input | done | #6176; `vector_ui.rs`, `vector_paint_tests.rs`: typed and pasted hex, partial/invalid input, alpha retention, next-shape defaults and selected-shape Undo | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -135,16 +141,16 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Drag outside rotates (⇧ 15°), angle readout; ↵ rotates and crops in one undo step | done | TOOL-214-18 | | Straighten button and ⌘-drag line | done | TOOL-214-21 | -| Default-mode resize snaps handles without camera feedback; image and frame use the same camera in each rendered frame | done | `crop_mode.rs::a_live_resize_snaps_the_handle_without_feedback_from_the_camera`, `tests/crop_default_mode.rs::a_resize_renders_the_frame_and_image_with_the_same_camera` (2026-10-10) | -| Ratio presets and W × H changes immediately fit the current frame around its centre; Swap button and X share the fitted ratio, Enter needs no resize | done | `crop_ui.rs::choosing_a_ratio_fits_and_commits_without_a_resize`, `crop_size.rs::choosing_a_size_with_the_canvas_ratio_commits_without_a_resize`, `tests/crop_default_mode.rs` (2026-10-10) | +| Default-mode resize snaps handles without camera feedback; image and frame use the same camera in each rendered frame | done | `crop_mode.rs::a_live_resize_snaps_the_handle_without_feedback_from_the_camera`, `tests/it/crop_default_mode.rs::a_resize_renders_the_frame_and_image_with_the_same_camera` (2026-10-10) | +| Ratio presets and W × H changes immediately fit the current frame around its centre; Swap button and X share the fitted ratio, Enter needs no resize | done | `crop_ui.rs::choosing_a_ratio_fits_and_commits_without_a_resize`, `crop_size.rs::choosing_a_size_with_the_canvas_ratio_commits_without_a_resize`, `tests/it/crop_default_mode.rs` (2026-10-10) | | Roomy Crop options bar does not scroll; narrow tool bars retain horizontal scrolling | done | `panels.rs::a_wide_crop_options_bar_does_not_scroll`, `panels.rs::narrow_tool_options_can_scroll_to_the_last_control` (2026-10-10) | | Overlays (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O | done | TOOL-214-20 | | Crop shield, Show Cropped Area (H) | done | TOOL-214-22 | | Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 | | W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) | -| Saved custom crop presets | missing | TOOL-214-9 | -| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) | -| Delete Cropped Pixels off | partial | | +| Saved custom crop presets (New / Delete Crop Preset, kept with the preferences; moving them to another computer is not offered) | done | TOOL-214-9 (`crop_presets.rs`, `tool.presets.*` with tool `crop`, #2723) | +| Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/it/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) | +| Delete Cropped Pixels off: the crop only hides the pixels outside the frame; editing the frame again shows them past the canvas and a recrop handle snaps to their edge | done | `image.crop` `deleteCroppedPixels`, the options-bar checkbox (`panels.rs`), `tests/it/crop_beyond_canvas.rs::editing_a_crop_frame_shows_the_pixels_past_the_canvas`, `crop_mode.rs::a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge` (#3638, #3640) | ## Snapping, guides, rulers, precision @@ -156,23 +162,28 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Smart guide distance and spacing labels | missing | | | Ruler units px/in/cm/mm/pt/pica/% | done | | | ⇧-drag a guide out of a ruler: it lands on the nearest ruler tick | done | `rulers.rs` `snap_to_tick` (#3770) | +| A dragged guide shows its position beside the pointer (X for vertical, Y for horizontal, in the ruler unit) | done | `rulers.rs` `guide_drag_readout` (#7680) | | Status bar follows the ruler unit | partial | #2602 | | Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 | | Guides stored as f64 | missing | `Vec` (UI-217-20) | | Geometry precision | partial | `geom::Point` f64, paths f64; paint internals mostly f32 | | Numeric fields: arithmetic, scrubby labels | partial | units in expressions and double-click reset missing (UI-217-13) | +| Wheel steps a value field, ⇧ ×10: clicked into, wherever the pointer is; not clicked, a gesture that starts over it | partial | `field_wheel.rs` (#5473, #4061). Photoshop: a field takes the wheel once clicked into (shortcut guides, user reports); whether the pointer must stay over it is unconfirmed. Stepping on hover alone is Photopea's, kept deliberately | +| Type options bar: drag the "T" icon to change the font size | done | `type_tool.rs` `options_bar`: the icon scrubs the size field like the Character panel's icons (Shift x10, Alt fine), at layer or selection scope, one history step per drag, within 1–1296 pt (#6437) | ## Navigation | Behaviour | Status | Evidence | |---|---|---| | Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | +| Hand tool: ⌘ held = Zoom In, ⌥ (or ⌘⌥) held = Zoom Out; click steps, drag zooms as the Zoom tool does (scrubs with Scrubby Zoom on, else draws a zoom rectangle) | done | `hold_keys.rs` `hand_zoom` (#6434); not during Free Transform or once a pan has started | +| Hand tool: ⇧-drag / Scroll All Windows pans every open document; hold H for Bird's Eye View | missing | | | Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | -| Animated zoom | missing | unread pref; #566 | +| Animated zoom | done | `canvas.rs`, `zoom_tool.rs`, `zoom_hold.rs`; discrete Zoom clicks and View zoom controls ease over 220 ms with anchored transitions and immediate direct navigation (#566); holding a Zoom press still past 0.3 s zooms continuously about the press point (2 doublings a second, ⌥ out) until release (#6210); General › Animated Zoom (default on) controls both behaviors; off snaps discrete zoom immediately, including when disabled mid-transition | | Double-click Zoom tool = 100%, double-click Hand = fit | partial | Zoom missing (#2508) | -| ⌥-wheel zoom in gentle steps | missing | UI-217-18 | +| ⌥-wheel zoom around the pointer | partial | ×1.1 per notch, measured on Photoshop (#1718; `wheel_nav.rs`, `input_tests.rs`); #217 asked for ~5 % steps (UI-217-18) | | Wheel, smooth scrolling and pinch as in Photoshop | partial | #2485, #2463, #1989 | | Trackpad rotate drives Rotate View | done | `rotate_view.rs:136` | | Zoom levels to 3200% and beyond | partial | #1813 | @@ -194,7 +205,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | -| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | +| Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | a guide let go over either ruler is removed, not only off the canvas (#6286) | ## Panels and workspace @@ -205,6 +216,7 @@ Paragraph; the rest open as in-app floating windows. | Behaviour | Status | Evidence | |---|---|---| +| Character and Paragraph controls without a type target | done | #5556; persistent Type tool defaults feed new point, box and mask text; existing text still edits in its selected scope (`type_tool.rs`, `type_tool_tests.rs`) | | Panel groups float and tear off into OS windows | missing | UI-217-5, #1828, #1213 | | Drag panels to reorder and regroup | partial | #2272, #2305 | | Docked resize pushes the neighbour | partial | #2573 | @@ -219,7 +231,7 @@ Paragraph; the rest open as in-app floating windows. | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | | Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name and Mode; the other six missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048) | -| Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | +| Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes; with a row selected, the Delete button and the ⌫/Delete key remove that filter, not the smart object | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271), `smart_ui::keys` (#4751) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | @@ -227,8 +239,17 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | +| 2026-10-17 | minor | The wheel steps value fields: a focused field takes it wherever the pointer is (the canvas and panels don't scroll), an unfocused one only a gesture that starts over it (#5473, #4061); no percentage changed | +| 2026-10-17 | minor | Object Finder foreground/background discrimination (#6548) | +| 2026-10-16 | minor | Hand tool ⌘ / ⌥ held are temporary Zoom In / Zoom Out (#6434); Scroll All Windows and Bird's Eye View rows added as missing | +| 2026-10-16 | minor | Type options bar: dragging the "T" icon scrubs the font size, as the Character panel's icons do (#6437) | +| 2026-10-16 | minor | Shape Fill/Stroke compact pickers accept six-digit hex RGB, including pasted values with `#`, retaining alpha and high-depth paint until an edit; typed/pasted colour and history regressions (#6176). Scores unchanged. | +| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) | +| 2026-10-15 | minor | A guide let go over either ruler is removed, not only off the canvas: the top ruler measures document x and the left one y, so the ruler a guide was not dragged out of can lie over a coordinate inside the document (#6286) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | +| 2026-10-14 | minor | Character and Paragraph panels edit persistent defaults without a type target (#5556); no percentage or estimate changed | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | | 2026-10-13 | minor | Smart object perspective (non-affine) corrected to done: #426 landed four-corner placements with PSD round trip; scorecard FILE-215-6 flipped | | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | @@ -236,13 +257,18 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | +| 2026-10-11 | minor | Animated zoom done: 220 ms transitions per view for discrete Zoom clicks and View controls; GPU composites are reused, coordinates follow the displayed camera, and direct navigation interrupts immediately | | 2026-10-14 | minor | History: a clicked snapshot stays selected and the trash deletes it; snapshot and state rows dragged onto the trash are deleted (#5324) | | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | +| 2026-10-12 | minor | General › Animated Zoom is visible and gates every discrete zoom transition; disabling it snaps all views to their target on the next frame | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | +| 2026-10-11 | minor | Selection modifiers at press: evidence now names the marquee, Lasso and Polygonal Lasso tests (still partial: no marquee ⇧⌥ intersect test). ⌥-wheel zoom: missing → partial (works at ×1.1 a notch; #217 asked for ~5 %) | +| 2026-10-21 | minor | Selection modifiers at press: partial → done; the marquee ⇧⌥ intersect test (`marquee_tests.rs` `shift_alt_before_the_drag_intersects_the_selection`) closed the gap the 2026-10-11 row names (BUG-208-4) | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-15 | minor | Animated zoom partial: with Animated Zoom on, holding the Zoom tool's button zooms continuously about the press point, ⌥ out (#6210) | | 2026-10-15 | minor | Pen Auto Add/Delete works on the path being drawn: a click on its segment adds an anchor, on a middle anchor deletes it, and drawing carries on (#6140) | | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | @@ -257,3 +283,9 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 2026-10-21 | minor | Crop rows corrected against the code and the scorecard: saved custom crop presets (TOOL-214-9, #2723) and Delete Cropped Pixels off are done (#6562); no percentage changed | +| 2026-10-20 | minor | Free Transform of an artboard child omits ancestor paper from the covering and isolated composites while retaining board clipping. The base canvas and whole-artboard backgrounds are preserved; synthetic regressions in `transform_tool.rs`. No parity percentage changed. | +| 2026-10-18 | minor | Move tool: dragging selected pixels shrinks the marching ants to the moved pixels as the drag starts, and the dropped selection is theirs, as in Photoshop (#7538) | +| 2026-10-18 | minor | Marching ants drawn in device pixels like Photoshop's: equally thick on every edge at 125 %/150 % display scale, thicker on HiDPI, slanted dash ends (#7489) | +| 2026-10-15 | minor | ⌫/Delete with a smart filter row selected removes that filter (the Smart Filters row: all of them), as the Delete button does, instead of the smart object (#4751) | +| 2026-10-14 | minor | Rounded corners on live triangles, polygons and stars: Properties field and on-canvas widgets (TOOL-214-12) | diff --git a/docs/user/troubleshooting/canvas-size-white-border.md b/docs/user/troubleshooting/canvas-size-white-border.md new file mode 100644 index 0000000000..f8a41eb5a3 --- /dev/null +++ b/docs/user/troubleshooting/canvas-size-white-border.md @@ -0,0 +1,32 @@ +--- +title: Canvas Size added a white border around my photo +description: Why Image > Canvas Size fills the new canvas with white around a photo, and how to extend the canvas with transparency instead. +order: 25 +verified: 0.11.1 +--- + +The photo did not change size. **Image** ▸ **Canvas Size…** only moves the layers; the new canvas area is filled with the **Canvas extension color**, which defaults to **Background** — your background color, usually white. Choose **Transparent** there to add empty canvas instead. + +## Symptoms + +- You open a photo, choose **Image** ▸ **Canvas Size…**, make the canvas larger and click **OK**. +- The photo's layer now has a white or background-colored border, so the layer looks resized or padded. +- The photo content itself keeps its size and position. + +## Fix + +1. Choose **Image** ▸ **Canvas Size…** again. +2. Set **Canvas extension color:** to **Transparent**. +3. Enter the canvas size you want and click **OK**. + +The new area is now empty. If you also want to change the photo's size, use **Image** ▸ **Image Size…**; that scales the image pixels. + +## Why this happens + +An opened JPEG or another fully opaque image becomes a locked **Background** layer, as in Photoshop. A Background layer cannot contain transparent pixels, so when the canvas grows, PhotoCraft fills the new area with the canvas extension color. **Background** is the default, so the border takes your background color — white until you change it. Layers that are not the Background are only moved and keep transparency. + +Canvas Size never scales layer pixels: it moves them by the **Anchor:** you choose. Scaling is **Image** ▸ **Image Size…**'s job. + +## Still stuck? + +If the photo itself is scaled or cropped, or the border appears with **Transparent** selected, report it in the [issue tracker](https://github.com/storytold/photocraft/issues) with your version from **Help** ▸ **System Info…** and a screenshot of the Canvas Size settings. diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md index 4785ae412b..a92501d040 100644 --- a/docs/user/troubleshooting/index.md +++ b/docs/user/troubleshooting/index.md @@ -8,4 +8,5 @@ verified: 0.8.2 - [PhotoCraft does not start](photocraft-does-not-start.md) - [The Windows taskbar turns black when PhotoCraft is maximized](taskbar-turns-black.md) - [Text moves when I start editing a PSD text layer](text-moves-when-editing.md) +- [Canvas Size added a white border around my photo](canvas-size-white-border.md) - [Get help with a problem](get-help.md) diff --git a/docs/windows-save-durability.md b/docs/windows-save-durability.md new file mode 100644 index 0000000000..a4dc5c70de --- /dev/null +++ b/docs/windows-save-durability.md @@ -0,0 +1,76 @@ +# Windows native-save durability + +An acknowledged overwrite must reopen as the complete new document after a Windows/NTFS +power loss. [Issue #7858](https://github.com/storytold/photocraft/issues/7858) demonstrated +that flushing a temporary file before replacement did not establish this guarantee: a fresh +Windows boot recovered the previous ZIP or directory bundle after Save returned success. + +## Publication barrier + +The shared atomic writer still writes and flushes a sibling temporary file, preserves the +destination's metadata, and replaces the destination. It now flushes the parent directory +on Windows before acknowledging success. `RealFs::sync_dir` opens a directory using +`FILE_FLAG_BACKUP_SEMANTICS` and `FILE_ADD_FILE`, then calls `File::sync_all`, which reaches +`FlushFileBuffers`. This uses ordinary directory permissions and does not open or flush a +volume or require administrative privileges. No unsupported `REPLACEFILE_WRITE_THROUGH` +flag is used. + +Windows references: + +- [Directory handles](https://learn.microsoft.com/en-us/windows/win32/fileio/obtaining-a-handle-to-a-directory) +- [File access rights](https://learn.microsoft.com/en-us/windows/win32/fileio/file-access-rights-constants) +- [Flushing cached data and directory structure](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fsa/0de7dc40-9627-437e-a4df-c4696cdc3d02) +- [Filesystem-specific behavior](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fsa/4e3695bd-7574-4f24-a223-b4679c065b63): the directory-structure guarantee applies to NTFS. +- [ReplaceFileW flags](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew) + +A publication-flush error occurs after replacement. It preserves the underlying error kind +and says that the new data was written but may not survive a crash. It does not claim the +old file is unchanged, attempt an unsafe rollback, or return success. Existing callers then +retain the document's dirty state and recovery data. + +## Directory bundles and retries + +Every published bundle file receives a Windows directory flush. Before replacing the +manifest, the writer also flushes `tiles/` and `blobs/`, including when it reuses cached +objects. A previous attempt can leave valid objects in place after a failed publication +flush; validating their bytes alone does not establish their durability. These barriers +make a retry using the same writer safe without recompressing every unchanged object. + +The manifest's publication barrier completes before obsolete objects are collected. An +error stops the save before that cleanup, preserving the objects referenced by the old +manifest. Garbage collection remains best effort after a successful commit. + +The private `save_dir_with` seam exercises this complete ordering with injected tile, +blob and manifest publication failures. The tests check the reported error, loaded +document, preservation of old objects, barriers on reused objects, and successful retry. + +## Verification + +Run the format tests and repository checks: + +```sh +cargo test -p photocraft-format +cargo clippy -p photocraft-format --all-targets -- -D warnings +cargo xtask layers +cargo xtask wasm +cargo xtask test-corpus -p io +``` + +Unit tests cannot prove power-loss durability. The VM test uses a disposable NTFS disk and +synthetic old/new documents at 8-, 16- and 32-bit depth, in ZIP and directory form. Flush the +old baseline during setup, call the production save API, wait for its successful response, +then abruptly power off the guest. Preserve and hash the crashed disk before recovery. +Boot Windows fresh against those exact bytes and compare the complete loaded document +and every expected bundle entry with the new revision. Do not restore a RAM snapshot for +recovery. Repeat each combination and retain failing unchanged-build controls. + +Keep volume flushes and clean unmounts in setup or diagnostic controls only; they are not +part of the production save implementation. Archive comparisons must account for the +build's generator-version stamp while still comparing all document payloads exactly. + +This change addresses Windows/NTFS publication. The existing Unix flush-error policy is +handled separately by [#5420](https://github.com/storytold/photocraft/pull/5420). Preview +replacement/deletion ordering on failed directory saves remains tracked by +[#7548](https://github.com/storytold/photocraft/issues/7548) and +[#7549](https://github.com/storytold/photocraft/issues/7549). VM tests do not establish +physical-controller or firmware-cache behavior, or guarantees on other filesystems. diff --git a/docs/xcf.md b/docs/xcf.md new file mode 100644 index 0000000000..afa4e2a42d --- /dev/null +++ b/docs/xcf.md @@ -0,0 +1,99 @@ +# GIMP XCF documents + +PhotoCraft opens GIMP's native `.xcf` files (and `.xcf.gz`) with their layers, read only. File › +Open, recent files, drops, the CLI (`convert`, `info`, `batch`) and automation (`doc.open`, MCP) +use the same reader in `photocraft-io` (`crates/io/src/xcf.rs`). A file is recognised by its +`gimp xcf ` signature, so an XCF with another extension still opens as one. Save suggests +`.pcraft`, which keeps everything PhotoCraft holds; exporting as `.xcf` fails with a message. + +## What is kept + +| | Open | +|---|---| +| Pixel layers (any size and position, also off-canvas), opaque or with alpha | yes | +| Groups, nested; a group in Pass through mode | yes | +| Names, visibility, opacity, offsets | yes | +| Layer masks | yes, applied or disabled as in GIMP | +| Blend modes | the 2.10 set and the legacy set, mapped to Photoshop's (table below) | +| Locks (alpha, content, position), colour tags | yes | +| Precision | 8 and 16-bit integer as such; 32-bit integer as 16-bit; 16/32/64-bit float as 32-bit float | +| Linear-light precisions | the document gets a linear sRGB (or linear gray) profile | +| Colour profile (`icc-profile` parasite), resolution, guides, comment | yes | +| Grayscale, RGB | as such; indexed images open as RGB | +| Extra channels, the saved selection | yes (alpha channels, the selection) | +| Text, vector and link layers | as their pixels, with a note | +| Compression | none, RLE, zlib | +| XCF versions | 0 to 26 (GIMP 1.x to 3.2); newer files open with a note, unknown parts skipped | + +Not kept, each with a note on open: non-destructive layer effects (GIMP 3's layer filters; the +layer opens unfiltered), paths, floating selections, item sets, sample points. Layer effects are +not representable without GEGL's operations; paths could follow later. + +## Blend modes + +| GIMP | PhotoCraft | +|---|---| +| Normal, Dissolve, Multiply, Screen, Overlay, Difference, Darken only, Lighten only, Divide, Hard light, Vivid light, Pin light, Linear light, Hard mix, Exclusion, Linear burn, Subtract | the same | +| Addition | Linear Dodge (Add) | +| Dodge, Burn | Color Dodge, Color Burn | +| Pass through (groups) | Pass Through | +| Soft light (both the 2.10 and the legacy one) | Soft Light (GIMP's formula differs a little; noted) | +| HSV Hue / Saturation / Value, HSL Color, LCH Hue / Chroma / Color / Lightness, Luminance | Hue, Saturation, Luminosity, Color (Photoshop's HSL-based modes; noted) | +| Luma darken only, Luma lighten only | Darker Color, Lighter Color (noted) | +| Behind, Grain extract, Grain merge, Color erase, Erase, Merge, Split | Normal, with a note | + +## How the composite differs from GIMP + +Three of GIMP's defaults have no Photoshop counterpart, so a file opened in PhotoCraft can render +a little differently from GIMP; the first two are noted on open when they apply: + +- **Linear light.** Since 2.10 GIMP blends most modes and composites every layer (alpha blending + too) in linear light by default. PhotoCraft, like Photoshop, composes in the document's own + encoding. Semi-transparent edges, masks and most blend modes therefore render slightly + differently: in the test files, a Multiply layer at 60% differs by up to 38/255 in one channel, + anti-aliased text edges by up to 73/255 at the edge pixels. A layer whose blend and composite + spaces GIMP set to *RGB (perceptual)* matches PhotoCraft within 2.5/255 (the `-perceptual` test + files). +- **Clip to backdrop.** GIMP composites every mode but Normal with *Clip to backdrop*: the layer + shows nothing where the layers beneath it are transparent. PhotoCraft shows it there. The + difference only appears over transparent areas of the stack. + +- **Clamping after alpha.** For modes whose result can leave 0..1 (Addition, Subtract, Divide, + Dodge, Burn, Linear light), GIMP applies the pixel's alpha and the layer's opacity first and + clamps at the end; Photoshop clamps the blend result first. Fully opaque pixels at 100% match + exactly; translucent ones differ where the raw result overshoots. + +Modes without an equivalent open as Normal, and the HSV/LCH/luma modes use Photoshop's HSL-based +formulas, so those layers render differently; the note names each layer. + +## Limits and safety + +Written from the public XCF specification (https://developer.gimp.org/core/standards/xcf/) and +files GIMP 3.2 writes; no GIMP code. Every read is bounds-checked and returns an error on a +malformed file, never a panic: canvases at most 300,000 px a side and 1 GiB of decoded pixels, +at most 8,192 layers and 1,024 channels, nesting at most 64 deep, property payloads at most +64 MiB, strings 1 MiB, gzipped files inflating to at most 1 GiB. Tile data is decoded into the +exact size the drawable declares; a run that crosses a tile or a zlib stream that ends early is an +error. The property lengths the specification warns about (`PROP_COLORMAP`, `PROP_USER_UNIT`) are +computed from the payload instead of trusted. Truncated files are covered by tests at every cut. + +## Verification + +Unit tests (`crates/io/src/xcf/tests.rs`) build XCF files from the specification with a small +writer and check every compression, precisions, pointer widths, groups by item path, masks, +channels, the colour map, parasites, limits and malformed input. + +Real files come from GIMP 3.2.6 itself: `gimp/` of [photocraft-corpus](https://github.com/storytold/photocraft-corpus) +(generator `tools/gimp-xcf/xcf_fixtures.py`, run inside GIMP), fetched into `corpus/gimp/` by +`cargo xtask corpus --gimp` (28 files, 251 KB): procedural layers the tests regenerate sample for +sample, and for each file GIMP's own merged rendering as a PNG. `crates/io/tests/it/xcf.rs` (feature +`corpus`, run by `cargo xtask test-corpus -p io`) checks the layer data against the formulas and our +composite against GIMP's: + +| File | Composite vs GIMP | +|---|---| +| `layers-8bit-perceptual`, `mask-perceptual`, `text-perceptual` | within 2.5/255 everywhere | +| `gray16`, `indexed`, `channel` | within 2/255 | +| `groups-perceptual` | within 2.5/255 except where Clip to backdrop applies (15% of pixels) | +| `legacy-modes-perceptual` | Addition, Subtract, Divide, Dodge, Burn, Hard light, Exclusion, Linear light within 2.5/255 on opaque pixels (translucent ones differ by the clamping order above); the approximated modes reported | +| the default (linear-light) files | the known gap, measured: up to 38/255 (Multiply), 59/255 (mask edge), 73/255 (text edge) | diff --git a/docs/zstd-workspaces-performance.md b/docs/zstd-workspaces-performance.md new file mode 100644 index 0000000000..24d2294e2c --- /dev/null +++ b/docs/zstd-workspaces-performance.md @@ -0,0 +1,47 @@ +# Native save writer reuse + +Each open document keeps a writer through the ordinary save settings. Unchanged tile and +blob identities reuse weakly cached hashes; compressed payload capacity retained between +saves is capped at 64 MiB. The last cache owner releases it. Other formats ignore the cache, +and one-shot exports still work. Save As names, previews, cancellation, atomic writes and +saved revisions keep their existing behavior. Tile hash identity includes sample depth. +Fallible edits move uniquely owned tile pixels when weak cache references remain; shared +pixels keep their copy-on-write behavior. + +The format writer's compressed-cache limit defaults to unbounded for existing callers. +The UI selects 64 MiB. This is retained memory that a one-shot save would release; it does +not cap current-save scratch or ZIP output, and it does not reduce bytes written to disk. + +## Measurements + +Release build on Apple M4 Max. CC0 Pixls foliage (24 MP, unscaled crop) and city (12 MP, +native size) edits contain a photograph, global Levels, Curves and masked foreground Levels. +ProPhoto RGB is retained. U8/U16/F32 are tested; F32 derives from developed U16 samples. +Two upstream PSD designs add authored layered-file coverage. No generated noise is timed. + +The table uses seven alternating pairs after warmup and includes thumbnail/composite +rendering, encoding, APFS atomic writes, fsync and rename. Hashing, reload equality and file +removal are outside timers. Intervals are paired bootstrap 95% descriptions of these samples. +These writer-reuse results compare main `d95fdf31` and the original parent `f64a143c` +with `05bd004b`; the original parent's production save path matches that main baseline. +Each small edit changes one real-photo tile. The later cache-ownership changes remove +redundant allocations and reference-count updates; no separate timing gain is claimed. + +| Document | Operation | Main ms | Original parent ms | Writer reuse ms | Change vs original parent | +|---|---|---:|---:|---:|---:| +| 24 MP foliage, U16 | save-first-disk | 966.0 | 966.0 | 984.9 | +2.0% (-8.5..+2.7) | +| 24 MP foliage, U16 | save-repeat-disk | 983.0 | 983.0 | 651.8 | -33.7% (-35.8..-30.9) | +| 24 MP foliage, U16 | save-edit-disk | 968.6 | 968.6 | 640.1 | -33.9% (-34.2..-33.0) | +| 12 MP city, U8 | save-first-disk | 660.8 | 660.8 | 621.6 | -5.9% (-7.5..+1.1) | +| 12 MP city, U8 | save-repeat-disk | 658.9 | 658.9 | 496.6 | -24.6% (-26.4..-18.8) | +| 12 MP city, U8 | save-edit-disk | 628.4 | 628.4 | 502.2 | -20.1% (-26.3..-17.8) | + +An independent nine-pair U16 first-save check changed its median delta from +2.0% to -5.9%, +with both intervals crossing zero; no repeatable first-save regression or speedup is established. +Initial in-memory repeated-export medians across six photo/depth cases improve 18–47%. +The independent disk runs confirm 20–34% gains for repeated and small-edit saves. +Peak RSS is close to main; the retained-payload idle-memory cost still applies. + +Every output is byte-identical to the baseline and reloads to the complete source document. +Scoped tests cover cache capacity/output, weak identities, sample depth, Save As, lifetime and +save-settings forwarding. Existing save-identity tests cover failed encoding/writing and copies. diff --git a/perf/budgets.toml b/perf/budgets.toml index 641c13a74f..0b068ede0f 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -60,6 +60,12 @@ package = "photocraft-ui-egui" args = ["--size", "14000x14000", "--op", "refresh"] note = "196 MP: open and full refresh" +[[bench]] +name = "large_file_bench" +package = "photocraft-ui-egui" +args = ["--reps", "3"] +note = "generated 2 GB PSD and 4.4 GB PSB (6.4 GB cached in /fixtures): read + decode + open; needs ~30 GB of memory (the PSB)" + # Hook: the layout-document bench lands with perf/layout-documents (#125/#128). It runs (and its # rows are kept in results.json) as soon as the example exists; add scenarios for its rows then. [[bench]] @@ -250,12 +256,23 @@ bench = "perf_scenarios" row = "open 20 MP layered PSD (decode + first composite)" budget_ms = 3000 +# P20/P63: generated fixtures (`photocraft_psd::fixtures`, #5107); the row checks a pixel, so a +# blank document fails it. No time budget yet: the target is the behaviour (progress, editable). [[scenario]] id = "P20" -title = "Open a 2 GB PSD / 4–5 GB PSB" +title = "Open a 2 GB PSD: editable" issue = 210 +bench = "large_file_bench" +row = "open 2 GB PSD (read + decode + document)" +target = "opens with progress, editable, never blank" + +[[scenario]] +id = "P63" +title = "Open a 4–5 GB PSB: editable" +issue = 210 +bench = "large_file_bench" +row = "open 4.4 GB PSB (read + decode + document)" target = "opens with progress, editable, never blank" -not_measurable = "opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216)" [[scenario]] id = "P21" diff --git a/scorecard/automation.toml b/scorecard/automation.toml index ed3839e73f..862de726f2 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -79,4 +79,4 @@ id = "AUTO-219-10" target = "Agent acceptance suite grown to 30-50 workflows" status = "partial" issue = 219 -note = "automation/tests/agent_tasks.rs runs 10 workflows over MCP" +note = "automation/tests/it/agent_tasks.rs runs 10 workflows over MCP" diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index a4620bf7d6..6b7e9a3ee2 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -60,7 +60,7 @@ id = "BUG-204-2" target = "workspace.large_tabs, animated_zoom, auto_show_home_screen wired or hidden" status = "done" issue = 204 -note = "workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; animated_zoom is in prefs::HIDDEN_UNTIL_IMPLEMENTED" +note = "workspace.large_tabs controls document tab height in canvas.rs; auto_show_home_screen drives the Home screen; general.animated_zoom gates discrete canvas zoom transitions and continuous held Zoom tool presses (zoom_hold.rs, #6210), and is exposed in General preferences" [[item]] id = "BUG-204-3" @@ -88,7 +88,7 @@ id = "BUG-204-6" target = "Test: every Prefs field is read somewhere or on a hidden allowlist" status = "done" issue = 204 -note = "crates/engine/tests/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both" +note = "crates/engine/tests/it/prefs_usage.rs every_preference_is_read_or_hidden: each field is read outside prefs.rs or listed in prefs::HIDDEN_UNTIL_IMPLEMENTED, never both" [[item]] id = "BUG-205-1" @@ -137,14 +137,14 @@ id = "BUG-206-2" target = "PSD import keeps Optical and manual Kerning values" status = "done" issue = 206 -note = "io/tests/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348)" +note = "io/tests/it/psd_kerning.rs manual_and_off_kerning_round_trip_through_psd and optical_kerning_round_trips_through_psd import an exported layer and compare every character's (mode, kern) pair; the optical mode travels in the document Txt2 block (#1348)" [[item]] id = "BUG-206-3" target = "PSD export writes the real AutoKerning mode and Kerning values" status = "done" issue = 206 -note = "manual_and_off_kerning_round_trip_through_psd (io/tests/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so" +note = "manual_and_off_kerning_round_trip_through_psd (io/tests/it/psd_kerning.rs) reads the AutoKerning flag and Kerning value of every character back out of the written TySh: (false, 100) after A, (true, -50) after T, and automatic kerning off where the style says so" [[item]] id = "BUG-206-4" @@ -158,7 +158,7 @@ id = "BUG-206-5" target = "Tests: PSD round trip of manual and Optical kerning, a real file in the oracle corpus" status = "partial" issue = 206 -note = "Round trips covered by io/tests/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values" +note = "Round trips covered by io/tests/it/psd_kerning.rs (manual, Off and Optical, plus hostile_kerning_values and txt2_extras_survive_a_resave_of_unchanged_text); corpus_text_layers reads real PSDs and applies their Txt2 optical kerning, but no oracle file is dedicated to kerning values" [[item]] id = "BUG-207-1" @@ -191,9 +191,9 @@ note = "edit.transform follows a targeted alpha channel or Quick Mask (explicit [[item]] id = "BUG-207-5" target = "Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe" -status = "missing" +status = "done" issue = 207 -note = "Only smudge_drags_colour_along_the_stroke (retouch.rs:407)" +note = "Every retouch tool (Clone Stamp, Pattern Stamp, Healing, Spot Healing, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush, Remove) is tested to paint only a targeted mask and only a targeted alpha channel (engine retouch_cmds/tests.rs:786 retouch_tools_paint_only_the_targeted_mask, :805 retouch_tools_paint_only_the_targeted_alpha_channel; Healing, Spot Healing, Sponge and Remove are checked to leave the rest untouched, not to change the target, since they may leave grey noise as it was); the other eight follow the Channels panel target (:822 retouch_tools_follow_the_channels_panel_target); Quick Mask and bad targets: :840 retouch_tools_paint_the_quick_mask, :853 retouch_targets_fail_gracefully. Sample All Layers changes the result over two layers (:879 sample_all_layers_blur_and_sharpen_work_on_an_empty_layer, :907 sample_all_layers_smudge_drags_colour_from_below, :923 sample_all_layers_spot_healing_heals_onto_an_empty_layer, :313 clone_stamp_sample_all_layers_onto_empty_layer_and_opacity; ui retouch_ui.rs:818 sample_all_layers_reaches_the_command); no smudge fringe (:942 smudge_across_a_transparent_edge_has_no_dark_fringe). Brush/Eraser/Gradient/Bucket targets: engine tests.rs:1263 painting_can_target_the_layer_mask, channel_cmds/tests.rs:301 painting_filtering_and_adjusting_into_an_alpha_channel. Not covered: Pencil and Mixer Brush into a mask or channel; the Channels panel target for Healing, Spot Healing, Sponge and Remove" [[item]] id = "BUG-208-1" @@ -219,9 +219,9 @@ note = "canvas.rs marquee_readout draws the readout; marquee_tests.rs asserts di [[item]] id = "BUG-208-4" target = "Add/subtract/intersect from modifiers pressed before the drag, kept by a test" -status = "partial" +status = "done" issue = 208 -note = "Mods captured at press (canvas.rs:1679, used :1779); only a badge test (tool_feedback.rs:195)" +note = "Mods captured at press. Marquee: Shift at the press adds and Alt subtracts, with no square or centring (ui-egui marquee_tests.rs:361 modifiers_held_before_the_drag_pick_the_mode_not_the_shape), and Shift+Alt held at the press intersects (marquee_tests.rs:410 shift_alt_before_the_drag_intersects_the_selection). Lasso: Alt, Shift and Shift+Alt at the start subtract, add and intersect (lasso_ui/tests.rs:96 initial_alt_still_subtracts_and_initial_shift_still_adds). Polygonal Lasso: Shift or Alt at the first click keeps the selection in view, and a Shift polygon adds to it (polygon_lasso_tests.rs:209 modifiers_at_the_first_click_keep_and_combine_the_selection); no test commits an Alt (subtract) polygon" [[item]] id = "BUG-208-5" diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..557a253b7f 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -1,5 +1,5 @@ # Scorecard checklist: file compatibility (#215 formats, #216 measurement). -# Corpus numbers come from crates/io/tests/corpus.rs, not from here. +# Corpus numbers come from crates/io/tests/it/corpus.rs, not from here. # Statuses must be true on main. Verified against 4b53f62 on 2026-10-05 (smart objects: #215 PR). area = "file" title = "File compatibility" @@ -10,14 +10,14 @@ id = "FILE-215-1" target = "Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels" status = "done" issue = 215 -note = "psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded" +note = "psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/it/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded" [[item]] id = "FILE-215-2" target = "Smart Filters survive PSD save and re-open (filterFX descriptor read and written)" status = "partial" issue = 215 -note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" +note = "smart_map.rs: 41 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/it/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning" [[item]] id = "FILE-215-3" @@ -94,7 +94,7 @@ id = "FILE-215-13" target = "Video layer frames persist in .pcraft; PSD video layers mapped" status = "partial" issue = 215 -note = "format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping" +note = "format convert.rs:89 + tests/it/roundtrip.rs:282 persist frames; no PSD video mapping" [[item]] id = "FILE-215-14" @@ -108,7 +108,7 @@ id = "FILE-215-15" target = "Layer Style contour picker, curve editor, presets, .shc import/export" status = "partial" issue = 2912 -note = "Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file" +note = "Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. User contour library: Contour Editor New… saves the curve under a name (layer.layerStyle.contours.new/list/delete/load/save, kept with the preferences), the picker lists user contours after the standard ones and has Load/Replace/Save Contours… (.shc). Missing: Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file" [[item]] id = "FILE-215-16" @@ -127,16 +127,16 @@ note = "compose multichannel.rs:1 spot preview for Multichannel only" [[item]] id = "FILE-215-18" target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling" -status = "partial" +status = "done" issue = 215 -note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling" +note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, resample, metadata, TGA bits) and confirm remembers them as used. Export As › Image Size › Resample offers Photoshop's six methods (Bicubic Automatic default, Nearest Neighbor included; export_preset_cmds::RESAMPLE, unknown values are errors) for the exported copy only; also layer.exportAs `resample` and export presets (#5838). Tests: export_dialog nearest_neighbor_doubles_each_pixel_into_an_exact_block (8/16-bit), resample_choices_are_checked, resample_is_remembered_and_a_bad_saved_value_falls_back; layer_menu_cmds export_layer_resamples_with_the_chosen_method; tests/it/export_as_scaling.rs export_as_resample_menu_writes_nearest_neighbor_blocks" [[item]] id = "FILE-215-19" target = "Save As JPEG opens a JPEG Options dialog (quality 0-12, format options)" -status = "missing" +status = "partial" issue = 215 -note = "ExportSettings has jpeg_quality 1-100 only (lib.rs:118)" +note = "jpeg_options_ui.rs: File > Save, Save As and Save a Copy to a JPEG ask for Quality 0-12 (Low/Medium/High/Maximum) and Baseline / Baseline Optimized / Progressive before writing, starting at an opened JPEG's own quality (file_encoding) or else the Export preference; scripts pass quality/jpegFormat (file.saveAs, file.saveACopy, app.save, CLI --jpeg-format) and recorded actions replay the answer (discard_ui/tests/jpeg_tests.rs, codecs roundtrip jpeg_format_options_round_trip). Baseline Optimized fits the Huffman tables only at 4:4:4 (Gray, CMYK) or progressive: RGB is written 4:2:0, which zune-jpeg 0.5.15 decodes wrongly with fitted tables, as in Save for Web, so RGB Baseline Optimized writes the same file as Baseline. Missing: Matte, progressive Scans (3-5) and the size preview" [[item]] id = "FILE-215-20" @@ -162,16 +162,16 @@ note = "ci.yml has a corpus job (name: corpus tests) on every PR and on main: th [[item]] id = "FILE-216-2" target = "Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests" -status = "partial" +status = "done" issue = 216 -note = "4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI" +note = "Generated at test time by psd fixtures.rs (feature testgen; never committed). io tests/it/large_files.rs: layered_20_mp_psd_opens_and_saves (5472x3648, 5 layers in 2 groups, mask, drop shadow) and layout_with_150_layers_opens_and_saves (A4 300 ppi, 150 layers in 15 groups, masks, hidden layers, closed groups, drop shadows) run in every cargo test (~2 s each); opt-in psd_just_under_2_gb_opens_and_saves (17,000 px, 2.02 GB PSD) and psb_of_4_gb_opens_and_saves (25,000 px, 4.38 GB PSB, data past 4 GiB), each opened, pixel-checked, saved and re-opened; plus large_psb.rs (2.5 GB merged image). Open times: ui-egui large_file_bench (P20, P63). The 2 GB and 4 GB files are not run in CI (6.4 GB on disk; ~12 GB of memory for the PSD test, ~25 GB for the PSB test) (#5107)" [[item]] id = "FILE-216-3" target = "TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported" status = "partial" issue = 216 -note = "EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report" +note = "EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/it/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report" [[item]] id = "FILE-216-4" @@ -185,11 +185,11 @@ id = "FILE-216-5" target = "Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure" status = "partial" issue = 216 -note = "Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist" +note = "Every save path that exists is tested by saving and reading back: layered TIFF io tests/it/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/it/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist" [[item]] id = "FILE-216-6" target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands" status = "partial" issue = 216 -note = "automation/tests/agent_tasks.rs: 20 MCP tasks" +note = "automation/tests/it/agent_tasks.rs, agent_tasks_extended.rs and agent_tasks_more.rs: 36 MCP tasks" diff --git a/scorecard/tools.toml b/scorecard/tools.toml index efe2bb159e..6a1ac772f7 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -29,9 +29,9 @@ note = "paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J [[item]] id = "TOOL-213-4" target = "Content-Aware Move tool reusing inpaint completion" -status = "partial" +status = "done" issue = 213 -note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections" +note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, #4385); a selection over 256x256 shows a coarse result solved scaled down first, then the command's exact result as a second job (#6680; 24 MP document, release: 600x400 coarse 0.26 s then exact 0.8 s, 1500x1000 0.38 s then 4.1 s, 3000x2000 0.69 s then 9.4 s; was no preview above 256x256). Tests: content_aware_move_previews_large_selections_coarse_then_exact_at_all_depths, content_aware_move_coarse_preview_handles_transform_extend_and_limits, a_preview_within_the_full_resolution_budget_is_the_plain_solve_bit_for_bit, a_large_selection_shows_a_coarse_result_then_the_exact_one" [[item]] id = "TOOL-213-5" @@ -52,7 +52,7 @@ id = "TOOL-213-7" target = "Live stroke preview for every brush-based retouch tool" status = "partial" issue = 213 -note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp" +note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Not yet live: Mixer Brush (in PR #3627)" [[item]] id = "TOOL-213-8" @@ -136,7 +136,7 @@ id = "TOOL-214-4" target = "Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab" status = "partial" issue = 214 -note = "Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open" +note = "Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open" [[item]] id = "TOOL-214-5" @@ -190,16 +190,16 @@ note = "path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool [[item]] id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" -status = "partial" +status = "done" issue = 214 -note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" +note = "On-canvas corner radius widgets on every selected live rectangle, triangle, polygon and star (LiveShape::Polygon corner_radius, shape.create/edit cornerRadius, vector shapes::round_corners) with Path Selection, the Rectangle/Triangle/Polygon tools and Move + Show Transform Controls: drag rounds every corner of every selected shape alike, Alt one corner of a rectangle, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)" [[item]] id = "TOOL-214-13" target = "Mask Properties: Density and Feather controls" -status = "partial" +status = "done" issue = 214 -note = "Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls" +note = "Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs:34 pixel mask, :48 vector mask, :52 the sliders; mask_props_ui_tests.rs:39 pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, :81 mask_slider_drags_preview_live_and_undo_as_separate_gestures, :147 vector_mask_properties_edit_density_and_feather)" [[item]] id = "TOOL-214-14" diff --git a/scorecard/type.toml b/scorecard/type.toml index ee8a876a41..8ca1d5f190 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -9,14 +9,14 @@ id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" status = "partial" issue = 218 -note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type.attachToPath at, path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, the caret and selection following the curve, and PSD path text (it saves as point text)" [[item]] id = "TYPE-218-2" target = "Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret" -status = "missing" +status = "partial" issue = 218 -note = "Engine has TextDirection (text.rs:155); no UI" +note = "Paragraph section (Window > Paragraph and Properties) and the options bar have Auto / Left-to-right / Right-to-left buttons setting the paragraph direction through type.setStyle; the options bar's choice is also the direction new type starts with (type_tool.rs direction_items; paragraph_direction_buttons_set_and_show_the_direction, options_bar_direction_sets_selected_layers_and_new_type). Still missing: visual RTL caret (the arrow keys move in logical order), and PSD doesn't store the direction (crates/text psd.rs neither reads nor writes it, so it reopens as Auto)" [[item]] id = "TYPE-218-3" @@ -44,14 +44,14 @@ id = "TYPE-218-6" target = "Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206)" status = "done" issue = 218 -note = "Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning)" +note = "Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/it/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning)" [[item]] id = "TYPE-218-7" target = "Warp Text dialog previews live and opens with the layer's current warp" -status = "missing" +status = "done" issue = 218 -note = "view_cmds.rs:834 always seeds arc, bend 50" +note = "Type > Warp Text opens with the shown layer's warp, or None, never remembered values (filter_dialog.rs:503 warp_defaults; warp_text_dialog_tests.rs:80 warp_text_opens_with_the_type_layers_current_warp, :99 warp_text_opens_at_none_for_a_layer_without_warp, :108 remembered_warp_values_never_override_the_layers_warp). The dialog previews live without editing the document, Cancel leaves no step and OK is one undoable step (:138 warp_text_dialog_previews_the_warp_without_editing_the_document, :160 cancelling_warp_text_leaves_the_document_and_history_untouched, :179 confirming_warp_text_is_one_undoable_step); the reduced-proxy preview matches the full-size result (filter_dialog.rs:2150 warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy)" [[item]] id = "TYPE-218-8" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 7eff90c3ca..1940b3d10c 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -10,7 +10,7 @@ id = "UI-217-1" target = "Tool options, last Export As / Save for Web / Fill settings and font recents persist" status = "partial" issue = 217 -note = "Fill (fill_ui.rs:72) and Liquify (liquify_ui.rs:64, #418) keep their settings in Preferences (prefs.dialogs); no tool_options/export fields in Preferences; export_dialog.rs:21 png/85. Each painting tool keeps its own brush (paint_mouse.rs sync_tool_brush, #218), but those live in UiState, so they are lost at quit like the other tool options" +note = "Tool options (Preferences.toolOptions, tool_memory.rs, #2814; clamped to the options-bar ranges on restore), each painting tool's own brush (Preferences.toolBrushes + lastBrush, #6212) and the selected tool persist; Reset Tool / Reset All Tools (tool_reset.rs) clear them. Fill, Liquify, Export As (export_dialog.rs COMMAND) and Save for Web (file_ui.rs WEB_COMMAND) remember their last settings in prefs.dialogs. Missing: font recents (type.recentFonts is in the prefs audit's unused list)" [[item]] id = "UI-217-2" @@ -78,9 +78,9 @@ note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes e [[item]] id = "UI-217-11" target = "Soft dark and soft light themes with picker previews; outlined knobs in light themes" -status = "partial" +status = "done" issue = 217 -note = "theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing" +note = "theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); the light themes outline the slider knob with Tokens::knob_outline (dim text, at least 3:1 against dock, card and chrome; #7927)" [[item]] id = "UI-217-12" @@ -112,10 +112,10 @@ note = "Curves editor (adjust_editors.rs, dialog and Properties): Edit points / [[item]] id = "UI-217-16" -target = "Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks" +target = "Hue/Saturation: Saturation slider gets a hue-following colour track, Lightness a black-grey-white track" status = "done" issue = 217 -note = "saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue" +note = "saturation_track/lightness_track (adjust_editors.rs): grey -> the hue, recolouring as the Hue slider moves, and a fixed black -> grey -> white (#6539); saturation_track_follows_the_hue, lightness_track_is_black_grey_white_whatever_the_hue" [[item]] id = "UI-217-17" @@ -127,9 +127,9 @@ note = "panels.rs:1837 single field + hex/RGB readout" [[item]] id = "UI-217-18" target = "Alt+wheel zooms in gentle ~5 % steps; zoom controls uncramped" -status = "missing" +status = "partial" issue = 217 -note = "canvas.rs:1107-1125 no Alt handling" +note = "Alt+wheel zooms around the pointer, one step per notch applied the frame it arrives (wheel_nav.rs:94; input_tests.rs:443 alt_scroll_zooms_in_steps_around_the_pointer, wheel_nav.rs:448 alt_scroll_zooms_ten_percent_per_notch), but the step is x1.1 (10 %), not the ~5 % this row asks for: #1718 changed it from x1.05 after measuring Photoshop, so the row's target is in question. Status bar zoom field and Fit button fit inside the bar in every theme (status_bar_tests.rs:39 status_bar_controls_sit_inside_the_bar_with_even_margins, #2823); the Navigator zoom controls are not checked" [[item]] id = "UI-217-19" diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000000..73d114e6bc --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_photocraft +sonar.organization=storytold diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 25305d5435..b879946a6e 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -11,5 +11,6 @@ workspace = true [dependencies] serde_json = { workspace = true } serde = { workspace = true } +chrono = { workspace = true } # perf/budgets.toml and scorecard/*.toml (MIT OR Apache-2.0). toml = "1" diff --git a/xtask/atn-corpus.sha256 b/xtask/atn-corpus.sha256 new file mode 100644 index 0000000000..b6cfea8f9d --- /dev/null +++ b/xtask/atn-corpus.sha256 @@ -0,0 +1,16 @@ +8b778f618f0219e18a1bf3994561c1d428acba42c4b3fc499bf8a7e334483d9a alpha-utility/AlphaUtilityAdditions.atn +6421c4001e6fc0f3593f55a523c5ec973eff56521cfeb2943c45a2e79ec584bf atntools/test.atn +3401a0d51699c8c9b19b3b228078ed7adc21385250b6bf23c1b5ea57662cd19f bjango/Bjango Actions.atn +b240cd0aa441071fbdba2898f3f6c72f1e1be2117a8c80f673d937ecae65510c bjango/Bjango Adjustment Actions.atn +f35e6109187fcab10a08d64a5eb58e147678643d4bd3225c43e6d80e00499da5 bjango/Bjango Scale Actions.atn +8f550c2b77d6ba8348c23092bb2d6df7fd27195c3f6218f7fa27feccf7516e74 bkwld/Save layers for web.atn +3d6b254532ec231f26145d62ecde018bab3ac7ba6c2380304e875158d787e689 gif-creator/BannerActions.atn +057374895b523bc1b9bf1f7a7b4fcd1bf4e66467fd783d8dc667a9a15b8939fe sbaril/Dimension-v01.atn +9b8ae4c7f0ebf103f9f45f1c051dffdfb67fc9c083d291be9c29b740023d85f2 sbaril/Firefly-Actions.atn +56ea2003be73018b81dfee524e0a5bc3c31b5b110045430793e2dd03406e66a3 sbaril/Frequency-Separation.atn +7e1dd8db08e98b74020fd61bc20cfecb25100062a723c4367de4eaa903ea1e29 sbaril/Generative-Upscale-Bypass-4096px-Limit/Generative-Upscale-Bypass-4096px-Limit.atn +46f372beac54a565cf7dde30afe9c2a247c43a991635823c68b91e378c863b61 sbaril/Generative-Upscale-Bypass-4096px-Limit/Generative-Upscale-Bypass-4K-Limit-Topaz.atn +1fed6e67ce15623d02e543aeffb76c2c14165eecfb4e363611a6e0ec3982d592 sbaril/Harmonize-Enhancement.atn +76a2704d6cc2269293e1aa221724fd769b0e77b5c8731777d7f06991f8c85366 sbaril/Photo-v01.atn +9b47dec313d23b342b668a85ac3d10fef953bc0c7c50d980de3025b6d861c12e sbaril/Quick-Object-Extraction.atn +418ec00d0c732c70d328f271cb79823af850514a746c8ff78df95e230522449e trimmer/Trimmer.atn diff --git a/xtask/gimp-corpus.sha256 b/xtask/gimp-corpus.sha256 new file mode 100644 index 0000000000..707854ec55 --- /dev/null +++ b/xtask/gimp-corpus.sha256 @@ -0,0 +1,28 @@ +cd8d886607bc407c11c3b3ff1d95d43c80e5ca6c0dbe32bf0b206cc7cd3504a7 channel.png +89e0cdacd88ab5e705fd02b419327979a279f18ce6b99b40f6c5717dfe486a2e channel.xcf +cc434c6c01a4afd2bffd18061de9d3161516dd92f24c2477d61a780f5dc5ca59 float-linear.png +e6729fa602ccc2b4c5eef2f399ca78a35c4bc0910d4e05173de415a81118ffc5 float-linear.xcf +f2107656985c9c7c7c1c627435d0429844ce55bb00918a893c07fe391a2e6a14 gray16.png +9b17c916b3f89ada8bd8bc9602ac4d2064d30c79462518914ffcc2471fc79db7 gray16.xcf +97c7cd9fb8f7a64dc23f15a120dfc00707a84189820d51f78c0d46c8774e7582 groups-perceptual.png +2799a2de44db4d203a817edd719c4d55ef9de3e7e5376cabf8bdae1f47eec4d5 groups-perceptual.xcf +6be6bb24d71e329f61318d4663ceedb1d58a66739910156179a18e6bc8c7bebf groups.png +f69c752f4de23cdd1e12463502a9c9ae3af684df66c6f8e0cf947364e01e6a52 groups.xcf +0d19a18bbdf9a28eb0699de52e4b3689ccf4895dc43413ff8c7df3ecaab19f4b indexed.png +96c48a46e5b3e220f274fdb515cc24c4e3ceae609f0b4d68e60b9461b76bdbc2 indexed.xcf +5ae4ec19ac8b056c22cca9d6d752bddac606fdf7ba983d60956da261252dcf36 layers-8bit-perceptual.png +30080fcbb90075b45a1ad6ab674ff9df66127f27f95a3c979c9dea87523a52fa layers-8bit-perceptual.xcf +5ab8111ad492730a7e90ae1439eaa8482f32d546719c6a002abf1de58ab7b18d layers-8bit.png +b408c8f746a3a790781703291c59b3cd3d185a967327480a4bd4bbe39d8f8bce layers-8bit.xcf +54313f695507d20fa8ab7dc7ec82be59af5b86afdc21c7b662d56f2465776c0a legacy-modes-perceptual.png +9c3bc560a1a82dde34e1c41f79a0ddb277788b0cac55cf91294d9eddb2126a72 legacy-modes-perceptual.xcf +96cccee5d525a3c00a68cffd7fec285d70c8213c933ef88737ea9c1e4d740ea8 legacy-modes.png +f2bdb5c8e7ee2e7d0fac4ad12fb4919366bcc84cdf22ec091b70eb148f95fc36 legacy-modes.xcf +34925fd060e4e58a85fe8b3934ad7a4ff82287a54e385978a4f14124c393d25b mask-perceptual.png +d932f76b5feaf4f229157a3da75c7e5c85602b3b6b890a3396acc87c2ae4f31c mask-perceptual.xcf +1d21f0b2562032f0b319e02b9951de7333483cb6dfbaeeb3a5a266225da39596 mask.png +2fc690147c3fcda0ab56c6c76aa5709a57671ed65d58759b0ebaa7ac4a92432f mask.xcf +9a17b6f6b460d8b880cfceaa0906f99b9c0a45487a7e7b0738b814dabc2fa558 text-perceptual.png +819633fb66710f02e575ae2bea79220717537287fe59bef52542b187cd3b9eca text-perceptual.xcf +21702736824b70ca50bc9c53f6fa182cc9a5f173f6cc0e1c53fca5a454ec882e text.png +a6928228a237a1c611de0296ea42bfd713e40e069654a9d4ae57c89222fa1d2b text.xcf diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index e6c929a1be..4cd276d6c4 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -8,7 +8,8 @@ use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use crate::corpus_pins::{ - self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_SHA256, PNGSUITE_URL, PSD_TOOLS_COMMIT, + self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PHOTOCRAFT_CORPUS_GIMP_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_SHA256, + PNGSUITE_URL, PSD_TOOLS_COMMIT, }; use crate::pinned::{PinnedCorpus, USER_AGENT}; use crate::{cargo, root, run}; @@ -21,7 +22,8 @@ pub const PIXLS_CRATES: &[&str] = &["photocraft-raw"]; /// Corpus crates with a `heif` feature: test-corpus enables it so the HEIF corpus tests run. const HEIF_CRATES: &[&str] = &["photocraft-codecs", "photocraft-io"]; -/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates). +/// Paths whose changes make `test-corpus --changed` run (the file-format and rendering crates, +/// and the engine's `.atn` import and export). const CRITICAL: &[&str] = &[ "crates/psd/", "crates/io/", @@ -33,8 +35,9 @@ const CRITICAL: &[&str] = &[ "crates/format/", "crates/affinity/", "crates/raw/", + "crates/engine/src/atn_cmds", ]; -/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --photoshop [--local]] [--update-manifest]` +/// `cargo xtask corpus [--all | --pngsuite | --download | --psd | --psd-tools | --heif | --exr | --pixls | --affinity | --atn | --photoshop [--local] | --gimp [--local]] [--update-manifest]` pub fn cmd(args: &[&str]) -> Result<(), String> { let update = args.contains(&"--update-manifest"); let mut did = false; @@ -69,6 +72,10 @@ pub fn cmd(args: &[&str]) -> Result<(), String> { corpus_pins::AFFINITY.fetch(update)?; did = true; } + if args.contains(&"--atn") { + corpus_pins::ATN.fetch(update)?; + did = true; + } if args.contains(&"--photoshop") { if args.contains(&"--local") { corpus_pins::PHOTOSHOP.fetch_local(&local_clone()?, update)?; @@ -77,6 +84,14 @@ pub fn cmd(args: &[&str]) -> Result<(), String> { } did = true; } + if args.contains(&"--gimp") { + if args.contains(&"--local") { + corpus_pins::GIMP.fetch_local(&local_clone()?, update)?; + } else { + corpus_pins::GIMP.fetch(update)?; + } + did = true; + } if !did { list(); } @@ -95,7 +110,7 @@ fn local_clone() -> Result { } /// Fetches every corpus that is missing or stale (verified ones are left alone). With `local`, -/// `corpus/photoshop` is copied from the authoring clone of photocraft-corpus instead. With +/// `corpus/photoshop` and `corpus/gimp` are copied from the authoring clone of photocraft-corpus instead. With /// `update`, every pinned corpus is refetched and its manifest rewritten (`--update-manifest`). pub fn fetch_all(local: bool, update: bool) -> Result<(), String> { fetch_pngsuite()?; @@ -105,10 +120,11 @@ pub fn fetch_all(local: bool, update: bool) -> Result<(), String> { } /// Calls `fetch(corpus, from_clone, update)` for every pinned corpus; `from_clone` is set for -/// `corpus/photoshop` when `local`. +/// the photocraft-corpus sets (`corpus/photoshop`, `corpus/gimp`) when `local`. fn fetch_pinned(local: bool, update: bool, mut fetch: impl FnMut(&PinnedCorpus, bool, bool) -> Result<(), String>) -> Result<(), String> { for c in corpus_pins::ALL { - fetch(c, local && std::ptr::eq(*c, &corpus_pins::PHOTOSHOP), update)?; + let ours = std::ptr::eq(*c, &corpus_pins::PHOTOSHOP) || std::ptr::eq(*c, &corpus_pins::GIMP); + fetch(c, local && ours, update)?; } Ok(()) } @@ -136,6 +152,9 @@ fn list() { https://github.com/storytold/photocraft-corpus at {PHOTOCRAFT_CORPUS_COMMIT} manifest xtask/photoshop-corpus.sha256. Fetch: --photoshop (--photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO) + corpus/gimp/ [{}] our GIMP-authored XCF oracles with GIMP's PNG renderings (MIT OR Apache-2.0), + from the same repository at {PHOTOCRAFT_CORPUS_GIMP_COMMIT} + manifest xtask/gimp-corpus.sha256. Fetch: --gimp (--gimp --local copies from the clone) corpus/psd/ [{}] 170 small psd-tools + ag-psd files (MIT), psd-tools@{} and ag-psd@{}, manifest xtask/psd-corpus.sha256. Fetch: --psd corpus/psd-tools/ [{}] the full psd-tools test set (MIT) at {PSD_TOOLS_COMMIT} @@ -146,6 +165,8 @@ fn list() { manifest xtask/exr-corpus.sha256. Fetch: --exr corpus/affinity/ [{}] public Affinity documents and #1606 samples (CC0, MIT), with PNG references; manifest xtask/affinity-corpus.sha256. Fetch: --affinity + corpus/atn/ [{}] Photoshop Actions sets from seven public repositories (MIT, BSD, Unlicense); + manifest xtask/atn-corpus.sha256. Fetch: --atn corpus/pngsuite/ [{}] PngSuite (public domain), {PNGSUITE_URL}. Fetch: --pngsuite corpus/pixls/ [opt-in] real camera raws from raw.pixls.us (public domain), one per decode path plus the known-unsupported packed ORF. Fetch: --pixls (opt-in) @@ -153,6 +174,7 @@ fn list() { Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-manifest.", corpus.display(), status(corpus_pins::PHOTOSHOP.is_current()), + status(corpus_pins::GIMP.is_current()), status(corpus_pins::PSD_MIXED.is_current()), &PSD_TOOLS_COMMIT[..12], &AG_PSD_COMMIT[..12], @@ -163,6 +185,7 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-ma status(corpus_pins::EXR.is_current()), &OPENEXR_COMMIT[..12], status(corpus_pins::AFFINITY.is_current()), + status(corpus_pins::ATN.is_current()), status(png_ok), ); } diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index 908557feea..641d1ef263 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -14,6 +14,9 @@ use crate::pinned::{PinnedCorpus, Upstream}; /// https://github.com/storytold/photocraft-corpus: our Photoshop-authored oracle PSDs /// (`photoshop/`). Bump through a PR after committing there. pub const PHOTOCRAFT_CORPUS_COMMIT: &str = "f5b1178cab15309e05b7b504545df3c701f6d8fd"; +/// https://github.com/storytold/photocraft-corpus: our GIMP-authored XCF oracles (`gimp/`). Its own +/// pin, so that set can move without refetching `photoshop/`. Bump through a PR after committing there. +pub const PHOTOCRAFT_CORPUS_GIMP_COMMIT: &str = "fbe9227a48d06de87061e527541209a178fd685c"; /// https://github.com/psd-tools/psd-tools (MIT): `tests/psd_files` (main, 2026-10-05). pub const PSD_TOOLS_COMMIT: &str = "96eb134c17b2c65edf4c4151c0f00b802ada86c2"; /// https://github.com/Agamnentzar/ag-psd (MIT): `test/` (master, 2026-07-02). @@ -69,6 +72,31 @@ pub const PHOTOSHOP: PinnedCorpus = PinnedCorpus { }, }; +/// `corpus/gimp`: 14 XCF files written by GIMP 3.2.6 by the photocraft-corpus generator, each with +/// GIMP's merged rendering as a PNG (28 files), the oracles of `photocraft-io`'s XCF reader. +pub const GIMP: PinnedCorpus = PinnedCorpus { + name: "gimp", + dest: "gimp", + upstreams: &[Upstream { + prefix: "", + repo: "storytold/photocraft-corpus", + commit: PHOTOCRAFT_CORPUS_GIMP_COMMIT, + subdir: "gimp", + extras: &[("README.md", "README.md"), ("LICENSE-MIT", "LICENSE-MIT"), ("LICENSE-APACHE", "LICENSE-APACHE")], + }], + manifest: "xtask/gimp-corpus.sha256", + exts: &["xcf", "png"], + subset: false, + sources_md: |c| { + format!( + "# GIMP XCF oracle corpus\n\n`gimp/` of https://github.com/storytold/photocraft-corpus at commit {PHOTOCRAFT_CORPUS_GIMP_COMMIT} \ + (see `README.md`). MIT OR Apache-2.0, authored by the PhotoCraft contributors with GIMP 3.2.6. Fetched and sha256-verified by \ + `cargo xtask corpus --gimp` against `{}`. Gitignored; never commit these files.\n", + c.manifest + ) + }, +}; + /// `corpus/psd-tools`: the complete psd-tools test set. pub const PSD_TOOLS: PinnedCorpus = PinnedCorpus { name: "psd-tools", @@ -239,8 +267,75 @@ pub const AFFINITY: PinnedCorpus = PinnedCorpus { ) }, }; +/// Photoshop Actions files (`.atn`) from public repositories under permissive licences, the +/// real-file oracles of `photocraft-psd::atn` and the engine's `.atn` import (2026-10-12). +/// https://github.com/sbaril/Photoshop-Scripts (MIT): `Actions/`. +pub const SBARIL_COMMIT: &str = "622493c9c617c9d2ff38500e8e512c0ad421eed9"; +/// https://github.com/bjango/Bjango-Actions (BSD, `Help/License.md`): three sets at the root. +pub const BJANGO_COMMIT: &str = "0fdc790b4931fd36816e6056dd84c2aa0d3ff054"; +/// https://github.com/BKWLD/photoshop-actions (MIT). +pub const BKWLD_COMMIT: &str = "d1a5d24e02bf23f78659106473ef7d8c747bea00"; +/// https://github.com/mrijk/atntools (MIT): the fixture of a Rust `.atn` reader. +pub const ATNTOOLS_COMMIT: &str = "529db30b631f32bc7970121af0393853c2154fa0"; +/// https://github.com/damsmcfly/photoshop-cleaner-trimmer (Unlicense). +pub const TRIMMER_COMMIT: &str = "fd3814df2a625ed7a015633eb91d8fe2f5bbe9a0"; +/// https://github.com/uclagamelab/AlphaUtilityAdditions (BSD-2-Clause). +pub const ALPHA_UTILITY_COMMIT: &str = "7bdae950a7db4c6c2224bcaf08f4c17ec18958cb"; +/// https://github.com/overbyte/photoshop-gif-creator-script (MIT). +pub const GIF_CREATOR_COMMIT: &str = "9fb78e8c09a21678afc84ccb31a435bd242894df"; + +/// `corpus/atn`: Photoshop Actions sets from seven public repositories, one folder each. +pub const ATN: PinnedCorpus = PinnedCorpus { + name: "atn", + dest: "atn", + upstreams: &[ + Upstream { prefix: "sbaril/", repo: "sbaril/Photoshop-Scripts", commit: SBARIL_COMMIT, subdir: "Actions", extras: &[("LICENSE", "sbaril/LICENSE")] }, + Upstream { prefix: "bjango/", repo: "bjango/Bjango-Actions", commit: BJANGO_COMMIT, subdir: "", extras: &[("Help/License.md", "bjango/License.md")] }, + Upstream { prefix: "bkwld/", repo: "BKWLD/photoshop-actions", commit: BKWLD_COMMIT, subdir: "", extras: &[("LICENSE", "bkwld/LICENSE")] }, + Upstream { prefix: "atntools/", repo: "mrijk/atntools", commit: ATNTOOLS_COMMIT, subdir: "", extras: &[("LICENSE", "atntools/LICENSE")] }, + Upstream { + prefix: "trimmer/", + repo: "damsmcfly/photoshop-cleaner-trimmer", + commit: TRIMMER_COMMIT, + subdir: "", + extras: &[("LICENSE", "trimmer/LICENSE")], + }, + Upstream { + prefix: "alpha-utility/", + repo: "uclagamelab/AlphaUtilityAdditions", + commit: ALPHA_UTILITY_COMMIT, + subdir: "", + extras: &[("LICENSE", "alpha-utility/LICENSE")], + }, + Upstream { + prefix: "gif-creator/", + repo: "overbyte/photoshop-gif-creator-script", + commit: GIF_CREATOR_COMMIT, + subdir: "", + extras: &[("LICENSE", "gif-creator/LICENSE")], + }, + ], + manifest: "xtask/atn-corpus.sha256", + exts: &["atn"], + subset: false, + sources_md: |c| { + format!( + "# Photoshop Actions (.atn) corpus\n\nAction sets from public repositories, unmodified, each in its own folder with its licence: \ + `sbaril/` (https://github.com/sbaril/Photoshop-Scripts/tree/{SBARIL_COMMIT}/Actions, MIT), \ + `bjango/` (https://github.com/bjango/Bjango-Actions/tree/{BJANGO_COMMIT}, BSD), \ + `bkwld/` (https://github.com/BKWLD/photoshop-actions/tree/{BKWLD_COMMIT}, MIT), \ + `atntools/` (https://github.com/mrijk/atntools/tree/{ATNTOOLS_COMMIT}, MIT), \ + `trimmer/` (https://github.com/damsmcfly/photoshop-cleaner-trimmer/tree/{TRIMMER_COMMIT}, Unlicense), \ + `alpha-utility/` (https://github.com/uclagamelab/AlphaUtilityAdditions/tree/{ALPHA_UTILITY_COMMIT}, BSD-2-Clause), \ + `gif-creator/` (https://github.com/overbyte/photoshop-gif-creator-script/tree/{GIF_CREATOR_COMMIT}, MIT). \ + Fetched and sha256-verified by `cargo xtask corpus --atn` against `{}`. Gitignored; never commit these files.\n", + c.manifest + ) + }, +}; + /// Every pinned corpus, in fetch order. -pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &HEIF, &EXR, &AFFINITY]; +pub const ALL: &[&PinnedCorpus] = &[&PSD_MIXED, &PSD_TOOLS, &PHOTOSHOP, &GIMP, &HEIF, &EXR, &AFFINITY, &ATN]; /// `corpus/pixls`: real camera raws from (released into the public /// domain / CC0 by their photographers; see ATTRIBUTION.md). One file per decode path diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f016464..7136f90ecf 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -22,18 +22,18 @@ const USAGE: &str = "\ usage: cargo xtask commands: - layers enforce the crate dependency layering (plan/architecture.md §3) + layers enforce the crate dependency layering (docs/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) - corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] + corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --atn | --photoshop | --gimp] [--local] [--update-manifest] show where test corpora live and their pins (xtask/src/corpus_pins.rs), or fetch them into corpus/ (pinned commits, sha256-verified; --all = every corpus but pixls, - --photoshop --local copies from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO) + --photoshop/--gimp --local copy from ../photocraft-corpus or $PHOTOCRAFT_CORPUS_REPO) test-corpus [-p ]... [--changed] [--pixls] [--local] [-- ] fetch every corpus, then cargo test --release --features corpus on the corpus crates; --changed runs only if psd/io/codecs/compose/gpu/text/format/raw changed; --pixls (or -p raw) also fetches corpus/pixls and runs the raw corpus tests; - --local takes corpus/photoshop from the photocraft-corpus authoring clone + --local takes corpus/photoshop and corpus/gimp from the photocraft-corpus authoring clone stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity Photoshop menu parity; rewrites docs/parity-checklist.md i18n-coverage report stable UI translation coverage for registered languages @@ -106,7 +106,7 @@ pub fn metadata() -> Result { fn cmd_layers() -> Result<(), String> { let crates = layers::from_metadata(&metadata()?)?; - println!("Dependency layering (plan/architecture.md §3)\n"); + println!("Dependency layering (docs/architecture.md §3)\n"); println!("{:<28} {:<14} workspace deps", "crate", "layer"); for c in &crates { let ws: Vec = c diff --git a/xtask/src/perf.rs b/xtask/src/perf.rs index 3e49514c1c..c1890d1cbd 100644 --- a/xtask/src/perf.rs +++ b/xtask/src/perf.rs @@ -541,20 +541,27 @@ pub fn machine() -> Value { json!({"os": os, "os_version": os_version, "arch": arch, "cpu": cpu, "cores": cores, "ram_bytes": ram}) } -/// `YYYY-MM-DDTHH:MM:SSZ` for a Unix time (civil-from-days, proleptic Gregorian). +/// `YYYY-MM-DDTHH:MM:SSZ` for a Unix time (proleptic Gregorian, UTC). A time past 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 `iso_utc`), never a +/// silent reset to the epoch. Accepts the full `u64` range the old code did. pub fn iso_utc(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(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) + use chrono::Datelike; + // `secs / 86_400` is at most 213_503_982_334_601, so `days` always fits in `i64` (old code too). + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + 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 now_iso() -> String { @@ -927,6 +934,18 @@ pub fn render_summary(results: &Value, outcomes: &[Outcome], baseline_note: Opti mod tests { use super::*; + #[test] + fn iso_utc_handles_boundaries() { + assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso_utc(1_791_446_357), "2026-10-08T07:59:17Z"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(iso_utc(253_402_300_800), "10000-01-01T00:00:00Z"); + // A value past chrono's range still converts via the 400-year cycle (the old hand-rolled + // `iso_utc` did too): pin that output, never the epoch. + assert_eq!(iso_utc(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_utc(u64::MAX), "584554051223-11-09T07:00:15Z"); + } + const TOML: &str = r#" [settings] regression_pct = 15.0 diff --git a/xtask/src/pinned.rs b/xtask/src/pinned.rs index c18de369e5..baf798f1de 100644 --- a/xtask/src/pinned.rs +++ b/xtask/src/pinned.rs @@ -71,13 +71,53 @@ impl PinnedCorpus { .collect() } + /// Upper bound on directories visited and files collected during a single `collect` walk. + /// A corpus clone is operator-selected but can still contain attacker-planted symlinks; these + /// budgets keep a malformed tree from expanding into an unbounded traversal before the + /// manifest check runs. Real corpora are a few thousand files at most. + const MAX_DIRS: usize = 100_000; + const MAX_FILES: usize = 1_000_000; + fn collect(&self, dir: &Path, base: &Path, out: &mut Vec) { + let mut visited = std::collections::HashSet::new(); + let mut dirs = 0usize; + self.collect_inner(dir, base, out, &mut visited, &mut dirs); + } + + /// Walks `dir`, skipping directory symlinks and directories already seen through another + /// alias, and stops once the traversal or file budget is reached. `visited` holds resolved + /// directory identities (canonical paths) so two links to the same ancestor cannot make the + /// walk revisit it. + fn collect_inner(&self, dir: &Path, base: &Path, out: &mut Vec, visited: &mut std::collections::HashSet, dirs: &mut usize) { + if out.len() >= Self::MAX_FILES || *dirs >= Self::MAX_DIRS { + return; + } + // Record this directory by its resolved identity; bail if we have already walked it + // (a symlink cycle) or cannot resolve it. + match std::fs::canonicalize(dir) { + Ok(real) => { + if !visited.insert(real) { + return; + } + } + Err(_) => return, + } + *dirs += 1; let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { + if out.len() >= Self::MAX_FILES || *dirs >= Self::MAX_DIRS { + return; + } let p = e.path(); - if p.is_dir() { - self.collect(&p, base, out); - } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| self.exts.iter().any(|x| e.eq_ignore_ascii_case(x))) { + // Use the entry's own metadata (do not follow links) to classify it. A directory + // symlink is skipped entirely so aliases to ancestors cannot drive the recursion. + let Ok(ft) = e.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + self.collect_inner(&p, base, out, visited, dirs); + } else if ft.is_file() && p.extension().and_then(|e| e.to_str()).is_some_and(|e| self.exts.iter().any(|x| e.eq_ignore_ascii_case(x))) { out.push(p.strip_prefix(base).unwrap_or(&p).to_path_buf()); } } @@ -89,7 +129,11 @@ impl PinnedCorpus { self.collect(dir, dir, &mut found); let mut out = Vec::new(); for f in found { - let rel = f.to_string_lossy().replace('\\', "/"); + // Build the manifest-relative path from the path's own components, joined with `/`. + // This preserves platform filename semantics: a Unix file literally named `a\b` keeps + // its backslash instead of being split into a fake `a/b` traversal component. Reject + // anything that is not a sequence of plain names (absolute roots, `..`, etc.). + let rel = rel_manifest_path(&f).ok_or_else(|| format!("unsafe corpus path {}", f.display()))?; let bytes = std::fs::read(dir.join(&f)).map_err(|e| format!("read {rel}: {e}"))?; out.push((sha256::hex(&bytes), format!("{prefix}{rel}"))); } @@ -144,12 +188,14 @@ impl PinnedCorpus { } }; for (_, file) in files { + let to = safe_dest_join(&dest, file).ok_or_else(|| format!("{file}: escapes corpus destination"))?; let from = locate(file).ok_or_else(|| format!("{file}: no upstream"))?; - place(&from, &dest.join(file))?; + place(&from, &to)?; } - for (from, to) in extras { + for (from, to_rel) in extras { if from.is_file() { - place(from, &dest.join(to))?; + let to = safe_dest_join(&dest, to_rel).ok_or_else(|| format!("{to_rel}: escapes corpus destination"))?; + place(from, &to)?; } } std::fs::write(dest.join("SOURCES.md"), (self.sources_md)(self)).map_err(|e| format!("write SOURCES.md: {e}"))?; @@ -180,7 +226,8 @@ impl PinnedCorpus { let out = staging.join(format!("u{i}")); std::fs::create_dir_all(&out).map_err(|e| format!("create {}: {e}", out.display()))?; let mut tar = Command::new("tar"); - tar.arg("-xzf").arg(&tgz).arg("-C").arg(&out).arg(format!("{top}/{}", up.subdir)); + // macOS folder icons (`Icon\r`) can't be created on Windows and are never corpus files. + tar.arg("-xzf").arg(&tgz).arg("-C").arg(&out).arg("--exclude=Icon\r").arg(format!("{top}/{}", up.subdir)); for (from, _) in up.extras { // A root-level corpus selection (`subdir == ""`) already extracts the whole repo // root; adding the same root files as individual tar selectors fails on GNU tar. @@ -268,6 +315,49 @@ impl PinnedCorpus { } } +/// Builds a `/`-joined manifest-relative path from a scanned relative path, using only the path's +/// own `Normal` components. Returns `None` if any component is a root, prefix, current-dir, or +/// parent-dir (`..`) element, so a scanned entry can never introduce traversal. Literal +/// backslashes inside a single Unix filename are preserved (they are part of the name, not a +/// separator). +fn rel_manifest_path(rel: &Path) -> Option { + use std::path::Component; + let mut parts = Vec::new(); + for comp in rel.components() { + match comp { + Component::Normal(os) => parts.push(os.to_str()?.to_string()), + // Root, prefix, `.` and `..` are all rejected: a corpus entry must be a plain, + // forward relative path beneath the scan root. + _ => return None, + } + } + if parts.is_empty() { + return None; + } + Some(parts.join("/")) +} + +/// Joins a manifest-relative corpus path onto `dest`, guaranteeing the result stays within +/// `dest`. Rejects absolute paths, parent-directory components, and any entry that would resolve +/// outside the destination. Returns the concrete target path on success. +fn safe_dest_join(dest: &Path, rel: &str) -> Option { + use std::path::Component; + let rel_path = Path::new(rel); + let mut out = dest.to_path_buf(); + for comp in rel_path.components() { + match comp { + Component::Normal(os) => out.push(os), + // Any non-plain component (root, prefix, `.`, `..`) could escape the destination. + _ => return None, + } + } + // The join must have added at least one component and must remain under `dest`. + if out == *dest || !out.starts_with(dest) { + return None; + } + Some(out) +} + /// Hex sha256 of a corpus file (reused by every corpus fetch, `xtask/pixls.sha256` included). fn file_hex(path: &std::path::Path) -> Option { std::fs::read(path).ok().map(|b| crate::sha256::hex(&b)) @@ -316,3 +406,75 @@ pub fn fetch_pixls(update: bool) -> Result<(), String> { println!("pixls: {} files verified in {}", crate::corpus_pins::PIXLS_FILES.len(), dest.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::{rel_manifest_path, safe_dest_join}; + use std::path::{Path, PathBuf}; + + #[test] + fn rel_manifest_joins_normal_components_with_forward_slash() { + assert_eq!(rel_manifest_path(Path::new("a/b/c.psd")).as_deref(), Some("a/b/c.psd")); + assert_eq!(rel_manifest_path(Path::new("file.psd")).as_deref(), Some("file.psd")); + } + + #[test] + #[cfg(unix)] + fn rel_manifest_preserves_literal_backslash_in_unix_filename() { + // On Unix a backslash is an ordinary filename character, not a separator. The old code + // turned `weird\name.psd` into `weird/name.psd`, inventing a path component that the + // installer would then treat as a subdirectory. The component must be kept intact. + let rel = Path::new("weird\\name.psd"); + assert_eq!(rel_manifest_path(rel).as_deref(), Some("weird\\name.psd")); + } + + #[test] + fn rel_manifest_rejects_traversal_and_absolute() { + assert_eq!(rel_manifest_path(Path::new("../escape.psd")), None); + assert_eq!(rel_manifest_path(Path::new("a/../../escape.psd")), None); + assert_eq!(rel_manifest_path(Path::new("/abs/path.psd")), None); + assert_eq!(rel_manifest_path(Path::new("")), None); + } + + #[test] + fn safe_dest_join_contains_normal_paths() { + let dest = PathBuf::from("/corpus/psd"); + let got = safe_dest_join(&dest, "sub/dir/file.psd").expect("normal path allowed"); + assert!(got.starts_with(&dest)); + assert!(got.ends_with("sub/dir/file.psd")); + } + + #[test] + fn safe_dest_join_rejects_escapes() { + let dest = PathBuf::from("/corpus/psd"); + assert_eq!(safe_dest_join(&dest, "../../etc/passwd"), None); + assert_eq!(safe_dest_join(&dest, "/etc/passwd"), None); + assert_eq!(safe_dest_join(&dest, "a/../../b"), None); + // An empty relative path would resolve to `dest` itself, not a file within it. + assert_eq!(safe_dest_join(&dest, ""), None); + } + + #[test] + #[cfg(unix)] + fn collect_skips_directory_symlinks_and_cycles() { + use std::os::unix::fs::symlink; + + let tmp = std::env::temp_dir().join(format!("pinned-collect-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(tmp.join("real")).unwrap(); + std::fs::write(tmp.join("real/a.psd"), b"data").unwrap(); + // A symlink back to an ancestor: the old collector would follow it and recurse forever. + symlink(&tmp, tmp.join("real/loop")).unwrap(); + // A symlink to a sibling directory also must not be descended into. + symlink(tmp.join("real"), tmp.join("alias")).unwrap(); + + let corpus = + super::PinnedCorpus { name: "t", dest: "t", upstreams: &[], manifest: "t.sha256", exts: &["psd"], subset: false, sources_md: |_| String::new() }; + let mut out = Vec::new(); + corpus.collect(&tmp, &tmp, &mut out); + // Exactly one real file is found; symlinked directories are skipped (no cycle, no dupes). + assert_eq!(out, vec![PathBuf::from("real/a.psd")]); + + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/xtask/src/scorecard.rs b/xtask/src/scorecard.rs index f7f270bd00..e33b3eec48 100644 --- a/xtask/src/scorecard.rs +++ b/xtask/src/scorecard.rs @@ -4,7 +4,7 @@ //! - `perf/budgets.toml` + `perf/baseline.json`: the performance table (the baseline's full-run //! numbers against the budgets). A fresh `cargo xtask perf` result in `target/` is not used: //! publish it with `cargo xtask perf --update-baseline`. -//! - `crates/io/tests/corpus.rs`: the corpus floors (`Source { .. }` constants). +//! - `crates/io/tests/it/corpus.rs`: the corpus floors (`Source { .. }` constants). //! - `scorecard/*.toml`: per-area checklists (id, target, status, issue, note). //! - the prefs audit: `Preferences` fields that no code outside `prefs.rs` / `prefs_ui.rs` reads. //! - counted from the tree: never-crash attribute coverage, `docs/parity-checklist.md`'s live count. @@ -21,7 +21,7 @@ use serde_json::Value; use crate::perf; pub const OUT: &str = "docs/scorecard.md"; -const CORPUS_RS: &str = "crates/io/tests/corpus.rs"; +const CORPUS_RS: &str = "crates/io/tests/it/corpus.rs"; const PREFS_RS: &str = "crates/engine/src/prefs.rs"; // ---- checklists ------------------------------------------------------------------------------ @@ -522,7 +522,7 @@ pub fn render(inp: &Inputs) -> String { let mut s = String::from( "# PhotoCraft scorecard\n\nGenerated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, \ -`crates/io/tests/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \ +`crates/io/tests/it/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \ regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers \ here supersede estimates elsewhere.\n\n## Summary\n\n| Area | Done | Partial | Missing | Key numbers |\n|---|---:|---:|---:|---|\n", ); @@ -589,7 +589,7 @@ rows are targets the code doesn't meet yet: they are reported, and only their re s.push_str(&format!("\n## {}\n\n", c.title)); match c.area.as_str() { "file" => { - s.push_str("Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n"); + s.push_str("Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpus tests (cargo feature `corpus`, `cargo xtask test-corpus`, always run in CI) fail below them.\n\n| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |\n|---|---|---:|---:|---:|\n"); for k in &inp.corpora { let n = |v: Option| v.map_or_else(|| "–".into(), |x| x.to_string()); s.push_str(&format!("| {} | `{}` | {} | {} | {} |\n", k.label, k.dir, n(k.files), n(k.pass_floor), n(k.roundtrip_floor)));